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
+1562
-114
No files matched your search
@@ -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)));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user