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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+46
-3
@@ -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;
|
||||
|
||||
/**
|
||||
@@ -16,18 +18,38 @@ import pro.thinhha.mailsender.pgp.discovery.WkdFallback;
|
||||
* Map keys that are e-mail addresses must use bracket notation so Spring keeps the {@code @} and {@code .}
|
||||
* 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 enabled master switch
|
||||
* @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