mirror of
https://github.com/sneakybugmaster/mailsender.git
synced 2026-11-10 19:48:49 +00:00
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:
34 files changed
+1554
-106
No files matched your search
@@ -19,12 +19,38 @@ mvn verify
|
||||
| `mailsender-pgp-spring-boot-starter` | dependency bundle for applications |
|
||||
| `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
|
||||
|
||||
The demo app looks up and validates the recipient's key via WKD, then sends `DEMO_COUNT` test mails
|
||||
(default: 1 to `test@thinhha.pro`). With a valid key the mail is PGP/MIME-encrypted (RFC 3156); otherwise it is
|
||||
sent in plaintext and a warning is logged. The policy layer (REQUIRE / OPPORTUNISTIC / PLAINTEXT, split
|
||||
delivery) comes in milestone 5.
|
||||
The demo app sends `DEMO_COUNT` test mails (default: 1 to `test@thinhha.pro`) through `SecureMailSender` and logs
|
||||
the outcome per recipient: PGP/MIME-encrypted (RFC 3156) when a valid WKD key is found, otherwise plaintext.
|
||||
Set `MAIL_PGP_MODE=RELAY` and `RELAY_HOST`/`RELAY_PORT`/`RELAY_USERNAME`/`RELAY_PASSWORD` to send via a gateway.
|
||||
|
||||
```
|
||||
mvn -q install -DskipTests
|
||||
|
||||
+11
-6
@@ -117,8 +117,9 @@ mail.pgp:
|
||||
```
|
||||
- `RelaySecureMailSender` uses its own `JavaMailSender` pointed at the relay (the app's normal
|
||||
`spring.mail.*` sender is untouched).
|
||||
- REQUIRE/OPPORTUNISTIC cannot be enforced by the library in this mode; they are passed as a header hint
|
||||
and enforcement is the gateway's responsibility. Documented and logged at startup.
|
||||
- REQUIRE/OPPORTUNISTIC cannot be enforced by the library in this mode. With `policy-header` set, the strictest
|
||||
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.
|
||||
|
||||
## 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.
|
||||
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`.
|
||||
5. **Policy + delivery planner** — table-driven tests over key/no-key × REQUIRE/OPPORTUNISTIC/PLAINTEXT × To/Cc/Bcc;
|
||||
`EnvelopeMimeMessage`.
|
||||
6. **SecureMailSender (BUILT_IN + RELAY)**, `SendReport`, sender keys, metrics, autoconfig switch.
|
||||
5. **Policy + delivery planner** — `PolicyResolver`, `RecipientKeyResolver`, `DeliveryPlanner`, `MessageAssembler`,
|
||||
`EnvelopeMimeMessage`; tests over key/no-key × REQUIRE/OPPORTUNISTIC/PLAINTEXT × To/Cc/Bcc. ✅
|
||||
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.
|
||||
Demo app ✅ — encrypts when a valid key is found, otherwise sends plaintext (logged). Verified live:
|
||||
`test@thinhha.pro` → found via advanced method, fpr `ADA6EFA0CC1630D602889820112495670FF1B4F0`.
|
||||
8. **Hardening** — protected headers, actuator endpoint, docs.
|
||||
8. **Hardening** — Micrometer metrics, protected headers, actuator endpoint, docs.
|
||||
@@ -1,34 +1,22 @@
|
||||
package pro.thinhha.mailsender.demo;
|
||||
|
||||
import java.io.IOException;
|
||||
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.LoggerFactory;
|
||||
import org.springframework.boot.ApplicationArguments;
|
||||
import org.springframework.boot.ApplicationRunner;
|
||||
import org.springframework.mail.javamail.JavaMailSender;
|
||||
import org.springframework.mail.javamail.MimeMessageHelper;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import jakarta.mail.MessagingException;
|
||||
import jakarta.mail.internet.MimeMessage;
|
||||
import pro.thinhha.mailsender.pgp.discovery.DiscoveryResult;
|
||||
import pro.thinhha.mailsender.pgp.discovery.KeyDiscovery;
|
||||
import pro.thinhha.mailsender.pgp.mime.PgpMimeEncryptor;
|
||||
import pro.thinhha.mailsender.pgp.validation.CertificateValidator;
|
||||
import pro.thinhha.mailsender.pgp.validation.ValidationResult;
|
||||
import pro.thinhha.mailsender.pgp.sender.SecureMailSender;
|
||||
import pro.thinhha.mailsender.pgp.sender.SendReport;
|
||||
|
||||
/**
|
||||
* Looks up and validates the recipient's key, then sends {@code demo.count} test mails: PGP/MIME-encrypted when a
|
||||
* valid key was found, otherwise in plaintext (logged as a downgrade).
|
||||
* <p>
|
||||
* This calls the building blocks directly; the policy layer and {@code SecureMailSender} (milestones 5–6) will
|
||||
* replace this wiring.
|
||||
* Sends {@code demo.count} test mails through {@link SecureMailSender}, which encrypts per recipient according to
|
||||
* {@code mail.pgp.*} (or hands them to the relay in RELAY mode), and logs what happened to each recipient.
|
||||
*/
|
||||
@Component
|
||||
class TestMailRunner implements ApplicationRunner {
|
||||
@@ -36,88 +24,35 @@ class TestMailRunner implements ApplicationRunner {
|
||||
private static final Logger log = LoggerFactory.getLogger(TestMailRunner.class);
|
||||
|
||||
private final DemoProperties properties;
|
||||
private final KeyDiscovery keyDiscovery;
|
||||
private final CertificateValidator validator;
|
||||
private final PgpMimeEncryptor mimeEncryptor;
|
||||
private final JavaMailSender mailSender;
|
||||
private final SecureMailSender mailSender;
|
||||
|
||||
TestMailRunner(DemoProperties properties, KeyDiscovery keyDiscovery, CertificateValidator validator,
|
||||
PgpMimeEncryptor mimeEncryptor, JavaMailSender mailSender) {
|
||||
TestMailRunner(DemoProperties properties, SecureMailSender mailSender) {
|
||||
this.properties = properties;
|
||||
this.keyDiscovery = keyDiscovery;
|
||||
this.validator = validator;
|
||||
this.mimeEncryptor = mimeEncryptor;
|
||||
this.mailSender = mailSender;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run(ApplicationArguments args) throws MessagingException, IOException {
|
||||
String to = properties.to();
|
||||
DiscoveryResult discovery = keyDiscovery.discover(to);
|
||||
Optional<OpenPGPCertificate> key = usableKey(discovery, to);
|
||||
|
||||
public void run(ApplicationArguments args) throws MessagingException {
|
||||
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();
|
||||
MimeMessageHelper helper = new MimeMessageHelper(message, "UTF-8");
|
||||
if (properties.from() != null && !properties.from().isBlank()) {
|
||||
helper.setFrom(properties.from());
|
||||
}
|
||||
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("""
|
||||
Test mail %d of %d from the mailsender demo, sent %s.
|
||||
|
||||
Key discovery for %s: %s
|
||||
This message was sent %s.
|
||||
""".formatted(index, properties.count(), OffsetDateTime.now(), properties.to(),
|
||||
describe(discovery), encrypted ? "PGP/MIME-encrypted" : "in plaintext"));
|
||||
return message;
|
||||
}
|
||||
If you can read this in a PGP-capable client that shows it as encrypted,
|
||||
WKD discovery and PGP/MIME encryption worked.
|
||||
""".formatted(i, properties.count(), OffsetDateTime.now()));
|
||||
|
||||
private static String describe(DiscoveryResult result) {
|
||||
return switch (result) {
|
||||
case DiscoveryResult.Found found -> "found via " + found.source();
|
||||
case DiscoveryResult.NotFound ignored -> "no key published";
|
||||
case DiscoveryResult.Unreachable u -> "lookup failed (" + u.reason() + ")";
|
||||
};
|
||||
SendReport report = mailSender.send(message);
|
||||
for (SendReport.RecipientOutcome outcome : report.recipients()) {
|
||||
log.info("Test mail {}/{} to {}: {}{}", i, properties.count(), outcome.address(), outcome.outcome(),
|
||||
outcome.detail() == null ? "" : " (" + outcome.detail() + ")");
|
||||
}
|
||||
|
||||
private static String fingerprint(OpenPGPCertificate certificate) {
|
||||
return HexFormat.of().withUpperCase().formatHex(certificate.getFingerprint());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,23 @@ spring:
|
||||
mail.smtp.connectiontimeout: 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:
|
||||
to: ${DEMO_TO:test@thinhha.pro}
|
||||
from: ${DEMO_FROM:${SMTP_USERNAME:}}
|
||||
|
||||
+1
-1
@@ -80,6 +80,6 @@ class EncryptedTestMailTest {
|
||||
in.transferTo(inner);
|
||||
}
|
||||
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");
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -54,6 +54,6 @@ class PlaintextTestMailTest {
|
||||
assertThat(received).hasSize(2);
|
||||
assertThat(received[0].getAllRecipients()).extracting(Object::toString).containsExactly("test@thinhha.pro");
|
||||
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();
|
||||
}
|
||||
}
|
||||
+12
@@ -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);
|
||||
}
|
||||
}
|
||||
+140
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -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
|
||||
}
|
||||
+19
@@ -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;
|
||||
}
|
||||
}
|
||||
+35
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+66
@@ -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
|
||||
}
|
||||
+44
@@ -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);
|
||||
}
|
||||
}
|
||||
+24
@@ -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;
|
||||
}
|
||||
}
|
||||
+37
@@ -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
|
||||
}
|
||||
}
|
||||
+46
@@ -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));
|
||||
}
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
package pro.thinhha.mailsender.pgp.delivery;
|
||||
|
||||
/** Header a recipient was addressed in. */
|
||||
public enum RecipientType {
|
||||
TO, CC, BCC
|
||||
}
|
||||
+20
@@ -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();
|
||||
}
|
||||
}
|
||||
+38
@@ -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();
|
||||
}
|
||||
}
|
||||
+167
@@ -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();
|
||||
}
|
||||
}
|
||||
+93
@@ -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();
|
||||
}
|
||||
}
|
||||
+35
@@ -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>
|
||||
<artifactId>spring-boot-autoconfigure</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<!-- JavaMailSender -->
|
||||
<groupId>org.springframework</groupId>
|
||||
<artifactId>spring-context-support</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-configuration-processor</artifactId>
|
||||
@@ -32,6 +37,16 @@
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</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>
|
||||
|
||||
<build>
|
||||
|
||||
+116
-10
@@ -7,8 +7,11 @@ import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Properties;
|
||||
|
||||
import org.bouncycastle.openpgp.api.OpenPGPCertificate;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
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.Primary;
|
||||
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.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.Certificates;
|
||||
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.WkdKeyDiscovery;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Key discovery (pinned keys first, then WKD behind a cache), certificate validation and PGP/MIME encryption beans.
|
||||
* The primary {@link KeyDiscovery} bean is the combined chain; {@link CachingKeyDiscovery} is exposed separately so
|
||||
* applications can evict entries.
|
||||
* Wires {@link SecureMailSender} and its parts: key discovery (pinned keys first, then WKD behind a cache),
|
||||
* validation, policy, delivery planning and PGP/MIME encryption. Every bean backs off if the application defines
|
||||
* 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)
|
||||
@ConditionalOnProperty(prefix = "mail.pgp", name = "enabled", havingValue = "true", matchIfMissing = true)
|
||||
public class PgpMailAutoConfiguration {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(PgpMailAutoConfiguration.class);
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
StaticKeyDiscovery pgpStaticKeyDiscovery(PgpMailProperties properties) {
|
||||
Map<String, List<OpenPGPCertificate>> certificates = new LinkedHashMap<>();
|
||||
properties.staticKeys().forEach((email, resource) -> certificates.put(email, read(email, resource)));
|
||||
return new StaticKeyDiscovery(certificates);
|
||||
return new StaticKeyDiscovery(readAll("mail.pgp.static-keys", properties.staticKeys()));
|
||||
}
|
||||
|
||||
@Bean
|
||||
@@ -97,15 +113,105 @@ public class PgpMailAutoConfiguration {
|
||||
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()) {
|
||||
List<OpenPGPCertificate> certificates = Certificates.parse(in.readAllBytes());
|
||||
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;
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException("mail.pgp.static-keys[" + email + "]: cannot read " + resource, e);
|
||||
throw new UncheckedIOException(property + "[" + email + "]: cannot read " + resource, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+45
-2
@@ -8,6 +8,8 @@ import org.springframework.boot.context.properties.bind.DefaultValue;
|
||||
import org.springframework.core.io.Resource;
|
||||
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;
|
||||
|
||||
/**
|
||||
@@ -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}.
|
||||
*
|
||||
* @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")
|
||||
public record PgpMailProperties(
|
||||
@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 Cache cache,
|
||||
Map<String, Resource> staticKeys) {
|
||||
Map<String, Resource> staticKeys,
|
||||
Map<String, Resource> senderKeys,
|
||||
@DefaultValue Relay relay) {
|
||||
|
||||
public PgpMailProperties {
|
||||
recipients = recipients == null ? Map.of() : Map.copyOf(recipients);
|
||||
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(
|
||||
@@ -49,4 +71,25 @@ public record PgpMailProperties(
|
||||
@DefaultValue("5m") Duration errorTtl,
|
||||
@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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+41
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+76
@@ -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";
|
||||
};
|
||||
}
|
||||
}
|
||||
+71
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
+25
@@ -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);
|
||||
}
|
||||
+34
@@ -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
|
||||
}
|
||||
}
|
||||
+49
-1
@@ -11,7 +11,9 @@ 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.runner.ApplicationContextRunner;
|
||||
import org.springframework.mail.javamail.JavaMailSender;
|
||||
|
||||
import pro.thinhha.mailsender.pgp.discovery.CachingKeyDiscovery;
|
||||
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.WkdFetcher;
|
||||
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;
|
||||
|
||||
class PgpMailAutoConfigurationTest {
|
||||
|
||||
private final ApplicationContextRunner runner = new ApplicationContextRunner()
|
||||
.withConfiguration(AutoConfigurations.of(PgpMailAutoConfiguration.class));
|
||||
.withConfiguration(AutoConfigurations.of(MailSenderAutoConfiguration.class, PgpMailAutoConfiguration.class))
|
||||
.withPropertyValues("spring.mail.host=localhost");
|
||||
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
@@ -70,6 +76,48 @@ class PgpMailAutoConfigurationTest {
|
||||
.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
|
||||
void disabledCreatesNothing() {
|
||||
runner.withPropertyValues("mail.pgp.enabled=false")
|
||||
|
||||
+170
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user