Add OpenPGP and S/MIME signing of outgoing mail

mail.pgp.signing.type selects NONE (default), OPENPGP or SMIME, with one
key/certificate per From address.

- OPENPGP: plaintext copies become PGP/MIME multipart/signed (RFC 3156 s5,
  detached signature over the exact content entity bytes); encrypted copies
  are signed inside the encryption (combined method)
- SMIME: plaintext copies become S/MIME multipart/signed (RFC 8551, via
  Bouncy Castle bcjmail); encrypted copies are S/MIME-signed, then
  PGP/MIME-encrypted
- signed bodies are attached as raw bytes so they reach the wire unchanged;
  shared MIME helpers move to MimeEntities
- keys are validated at startup (signing capability, address match,
  validity, passphrase / key-certificate match)
- mail.pgp.signing.required refuses mail from addresses without a key
  (MailSigningException); RELAY mode signs before handing over
- SendReport.signature(), metric tag signature, refused{cause}
- core test-jar shares key/certificate and verification helpers
- end-to-end tests verify signatures on the bytes received over SMTP

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G71TQ5jDETVwdUJxGAwaiT
This commit is contained in:
Claude committed 2026-10-15 09:49:45 +00:00
1 parent fb52ee2c27
commit 7c2fb0142e
32 files changed
+1653 -133

No files matched your search

@@ -52,6 +52,13 @@
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>pro.thinhha.mailsender</groupId>
<artifactId>mailsender-pgp-core</artifactId>
<version>${project.version}</version>
<type>test-jar</type>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
@@ -9,7 +9,11 @@ import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.security.GeneralSecurityException;
import org.bouncycastle.openpgp.api.OpenPGPCertificate;
import org.bouncycastle.openpgp.api.OpenPGPKey;
import org.pgpainless.PGPainless;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.ObjectProvider;
@@ -45,6 +49,11 @@ import pro.thinhha.mailsender.pgp.sender.BuiltInSecureMailSender;
import pro.thinhha.mailsender.pgp.sender.RelaySecureMailSender;
import pro.thinhha.mailsender.pgp.sender.SecureMailSender;
import pro.thinhha.mailsender.pgp.validation.CertificateValidator;
import pro.thinhha.mailsender.signing.MailSigning;
import pro.thinhha.mailsender.signing.MessageSigner;
import pro.thinhha.mailsender.signing.OpenPgpMessageSigner;
import pro.thinhha.mailsender.signing.SignerResolver;
import pro.thinhha.mailsender.signing.SmimeMessageSigner;
/**
* Wires {@link SecureMailSender} and its parts: key discovery (pinned keys first, then WKD behind a cache),
@@ -154,15 +163,40 @@ public class PgpMailAutoConfiguration {
@Bean
@ConditionalOnMissingBean
MessageAssembler pgpMessageAssembler(PgpMimeEncryptor mimeEncryptor, SenderKeyProvider senderKeys) {
return new MessageAssembler(mimeEncryptor, senderKeys);
MailSigning pgpMailSigning(PgpMailProperties properties) {
PgpMailProperties.Signing signing = properties.signing();
List<MessageSigner> signers = new ArrayList<>();
switch (signing.type()) {
case NONE -> {
if (!signing.openpgp().isEmpty() || !signing.smime().isEmpty()) {
log.warn("mail.pgp.signing keys are configured but mail.pgp.signing.type=NONE: mail is not signed");
}
return MailSigning.disabled();
}
case OPENPGP -> signing.openpgp().forEach((address, key) -> signers.add(openPgpSigner(address, key)));
case SMIME -> signing.smime().forEach((address, keystore) -> signers.add(smimeSigner(address, keystore)));
}
if (signers.isEmpty()) {
throw new IllegalStateException("mail.pgp.signing.type=" + signing.type() + " but no mail.pgp.signing."
+ signing.type().name().toLowerCase(java.util.Locale.ROOT) + "[address] key is configured");
}
log.info("Signing mail from {} with {}", signers.stream().map(MessageSigner::address).toList(),
signing.type());
return new MailSigning(SignerResolver.of(signers), signing.required());
}
@Bean
@ConditionalOnMissingBean
MessageAssembler pgpMessageAssembler(PgpMimeEncryptor mimeEncryptor, SenderKeyProvider senderKeys,
MailSigning signing) {
return new MessageAssembler(mimeEncryptor, senderKeys, signing);
}
@Bean
@ConditionalOnMissingBean
SecureMailSender pgpSecureMailSender(PgpMailProperties properties, ObjectProvider<JavaMailSender> mailSender,
ObjectProvider<DeliveryPlanner> planner, ObjectProvider<MessageAssembler> assembler,
PolicyResolver policies, ObjectProvider<SecureMailSenderCustomizer> customizers) {
PolicyResolver policies, MailSigning signing, ObjectProvider<SecureMailSenderCustomizer> customizers) {
SecureMailSender sender = switch (properties.mode()) {
case BUILT_IN -> {
JavaMailSender delegate = mailSender.getIfAvailable();
@@ -176,7 +210,7 @@ public class PgpMailAutoConfiguration {
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());
yield new RelaySecureMailSender(relaySender(relay), policies, relay.policyHeader(), signing);
}
};
for (SecureMailSenderCustomizer customizer : customizers.orderedStream().toList()) {
@@ -204,6 +238,34 @@ public class PgpMailAutoConfiguration {
return sender;
}
private static MessageSigner openPgpSigner(String address, PgpMailProperties.Signing.OpenPgp properties) {
String property = "mail.pgp.signing.openpgp[" + address + "]";
if (properties.key() == null) {
throw new IllegalStateException(property + ".key is required");
}
try (InputStream in = properties.key().getInputStream()) {
OpenPGPKey key = PGPainless.getInstance().readKey().parseKey(in.readAllBytes());
char[] passphrase = properties.passphrase() == null ? null : properties.passphrase().toCharArray();
return new OpenPgpMessageSigner(address, key, passphrase);
} catch (IOException e) {
throw new UncheckedIOException(property + ": cannot read secret key " + properties.key(), e);
}
}
private static MessageSigner smimeSigner(String address, PgpMailProperties.Signing.Smime properties) {
String property = "mail.pgp.signing.smime[" + address + "]";
if (properties.keystore() == null) {
throw new IllegalStateException(property + ".keystore is required");
}
char[] password = properties.password() == null ? new char[0] : properties.password().toCharArray();
try (InputStream in = properties.keystore().getInputStream()) {
return SmimeMessageSigner.fromPkcs12(address, in, password, properties.alias());
} catch (IOException | GeneralSecurityException e) {
throw new IllegalStateException(property + ": cannot load keystore " + properties.keystore() + ": "
+ e.getMessage(), e);
}
}
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)));
@@ -11,6 +11,7 @@ import org.springframework.util.unit.DataSize;
import pro.thinhha.mailsender.pgp.delivery.EncryptionPolicy;
import pro.thinhha.mailsender.pgp.delivery.MixedMode;
import pro.thinhha.mailsender.pgp.discovery.WkdFallback;
import pro.thinhha.mailsender.signing.SignatureType;
/**
* {@code mail.pgp.*} configuration.
@@ -27,6 +28,7 @@ import pro.thinhha.mailsender.pgp.discovery.WkdFallback;
* @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
* @param signing signing of outgoing mail (OpenPGP or S/MIME)
*/
@ConfigurationProperties("mail.pgp")
public record PgpMailProperties(
@@ -40,6 +42,7 @@ public record PgpMailProperties(
@DefaultValue Cache cache,
Map<String, Resource> staticKeys,
Map<String, Resource> senderKeys,
@DefaultValue Signing signing,
@DefaultValue Relay relay) {
public PgpMailProperties {
@@ -75,6 +78,42 @@ public record PgpMailProperties(
@DefaultValue("10000") long maxSize) {
}
/**
* Signing of outgoing mail with the key/certificate of the From address.
*
* @param type {@code NONE}, {@code OPENPGP} or {@code SMIME}
* @param required refuse to send ({@code MailSigningException}) when the From address has no signing key;
* otherwise send unsigned and log a warning
* @param openpgp OpenPGP secret key per From address (type {@code OPENPGP})
* @param smime PKCS#12 keystore per From address (type {@code SMIME})
*/
public record Signing(
@DefaultValue("NONE") SignatureType type,
@DefaultValue("false") boolean required,
Map<String, OpenPgp> openpgp,
Map<String, Smime> smime) {
public Signing {
openpgp = openpgp == null ? Map.of() : Map.copyOf(openpgp);
smime = smime == null ? Map.of() : Map.copyOf(smime);
}
/**
* @param key armored or binary OpenPGP secret key
* @param passphrase passphrase of the key; empty for an unprotected key
*/
public record OpenPgp(Resource key, String passphrase) {
}
/**
* @param keystore PKCS#12 file with the private key and certificate chain
* @param password keystore and key password
* @param alias key entry to use; empty for the first key entry
*/
public record Smime(Resource keystore, String password, String alias) {
}
}
/**
* SMTP connection to the encrypting gateway ({@code mode=RELAY}). The hop carries plaintext, so STARTTLS is
* required by default.
@@ -11,10 +11,11 @@ import jakarta.mail.internet.MimeMessage;
import pro.thinhha.mailsender.pgp.delivery.EncryptionPolicyException;
import pro.thinhha.mailsender.pgp.sender.SecureMailSender;
import pro.thinhha.mailsender.pgp.sender.SendReport;
import pro.thinhha.mailsender.signing.MailSigningException;
/**
* Counts per recipient {@code mail.pgp.recipients{outcome, policy, reason}} and refused messages
* {@code mail.pgp.refused}. A plaintext recipient with policy {@code opportunistic} is a downgrade.
* Counts per recipient {@code mail.pgp.recipients{outcome, policy, reason, signature}} and refused messages
* {@code mail.pgp.refused{cause}}. A plaintext recipient with policy {@code opportunistic} is a downgrade.
*/
public final class MeteredSecureMailSender implements SecureMailSender {
@@ -39,7 +40,10 @@ public final class MeteredSecureMailSender implements SecureMailSender {
try {
return record(delegate.send(message));
} catch (EncryptionPolicyException e) {
refused();
refused("encryption_policy");
throw e;
} catch (MailSigningException e) {
refused("signing_required");
throw e;
}
}
@@ -49,7 +53,10 @@ public final class MeteredSecureMailSender implements SecureMailSender {
try {
return record(delegate.send(message));
} catch (EncryptionPolicyException e) {
refused();
refused("encryption_policy");
throw e;
} catch (MailSigningException e) {
refused("signing_required");
throw e;
}
}
@@ -61,15 +68,16 @@ public final class MeteredSecureMailSender implements SecureMailSender {
.tag("outcome", lower(recipient.outcome()))
.tag("policy", lower(recipient.policy()))
.tag("reason", lower(recipient.reason()))
.tag("signature", lower(report.signature()))
.register(registry)
.increment();
}
return report;
}
private void refused() {
Counter.builder(REFUSED).description("Messages refused by the encryption policy").register(registry)
.increment();
private void refused(String cause) {
Counter.builder(REFUSED).description("Messages refused by the encryption or signing policy")
.tag("cause", cause).register(registry).increment();
}
private static String lower(Enum<?> value) {
@@ -45,18 +45,19 @@ public final class BuiltInSecureMailSender extends AbstractSecureMailSender {
recipients(message, Message.RecipientType.CC),
recipients(message, Message.RecipientType.BCC));
List<MimeMessage> copies;
MessageAssembler.Assembly assembly;
try {
copies = assembler.assemble(message, plan, delegate::createMimeMessage);
assembly = assembler.assemble(message, plan, delegate::createMimeMessage);
} catch (MessagingException | IOException e) {
throw new MailPreparationException("Could not build encrypted message", e);
throw new MailPreparationException("Could not build signed/encrypted message", e);
}
List<MimeMessage> copies = assembly.messages();
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()
SendReport report = new SendReport(assembly.signature(), plan.decisions().stream()
.map(d -> new SendReport.RecipientOutcome(d.address(), d.type(), d.policy(),
d.encrypted() ? SendReport.Outcome.ENCRYPTED : SendReport.Outcome.PLAINTEXT,
d.encrypted() ? SendReport.PlaintextReason.NONE : reason(d.key()),
@@ -16,23 +16,36 @@ 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;
import pro.thinhha.mailsender.signing.MailSigning;
import pro.thinhha.mailsender.signing.MessageSigner;
import pro.thinhha.mailsender.signing.SignatureType;
/**
* 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.
* <p>
* If signing is configured, the message is signed here before it is handed over, so the gateway encrypts a signed
* message.
*/
public final class RelaySecureMailSender extends AbstractSecureMailSender {
private final JavaMailSender relay;
private final PolicyResolver policies;
private final String policyHeader;
private final MailSigning signing;
/** @param policyHeader header carrying the policy hint, or {@code null} for none */
public RelaySecureMailSender(JavaMailSender relay, PolicyResolver policies, String policyHeader) {
this(relay, policies, policyHeader, MailSigning.disabled());
}
public RelaySecureMailSender(JavaMailSender relay, PolicyResolver policies, String policyHeader,
MailSigning signing) {
this.relay = Objects.requireNonNull(relay, "relay");
this.policies = Objects.requireNonNull(policies, "policies");
this.policyHeader = policyHeader == null || policyHeader.isBlank() ? null : policyHeader;
this.signing = Objects.requireNonNull(signing, "signing");
}
@Override
@@ -47,6 +60,17 @@ public final class RelaySecureMailSender extends AbstractSecureMailSender {
addOutcomes(outcomes, recipients(message, Message.RecipientType.CC), RecipientType.CC);
addOutcomes(outcomes, recipients(message, Message.RecipientType.BCC), RecipientType.BCC);
SignatureType signature = SignatureType.NONE;
try {
MessageSigner signer = signing.signerFor(message).orElse(null);
if (signer != null) {
message = signer.sign(message, relay::createMimeMessage);
signature = signer.type();
}
} catch (MessagingException | java.io.IOException e) {
throw new MailPreparationException("Could not sign message", e);
}
if (policyHeader != null && !outcomes.isEmpty()) {
// Enum order is strictest first: REQUIRE, OPPORTUNISTIC, PLAINTEXT.
EncryptionPolicy strictest = outcomes.stream().map(SendReport.RecipientOutcome::policy)
@@ -58,7 +82,7 @@ public final class RelaySecureMailSender extends AbstractSecureMailSender {
}
}
relay.send(message);
return new SendReport(outcomes);
return new SendReport(signature, outcomes);
}
private void addOutcomes(List<SendReport.RecipientOutcome> outcomes, List<InternetAddress> addresses,
@@ -4,14 +4,25 @@ import java.util.List;
import pro.thinhha.mailsender.pgp.delivery.EncryptionPolicy;
import pro.thinhha.mailsender.pgp.delivery.RecipientType;
import pro.thinhha.mailsender.signing.SignatureType;
/** What happened to each recipient of a sent message. */
public record SendReport(List<RecipientOutcome> recipients) {
/**
* What happened to each recipient of a sent message.
*
* @param signature how the message was signed; {@link SignatureType#NONE} if it was not
*/
public record SendReport(SignatureType signature, List<RecipientOutcome> recipients) {
public SendReport {
signature = signature == null ? SignatureType.NONE : signature;
recipients = List.copyOf(recipients);
}
/** An unsigned message. */
public SendReport(List<RecipientOutcome> recipients) {
this(SignatureType.NONE, recipients);
}
public boolean allEncrypted() {
return recipients.stream().allMatch(r -> r.outcome() == Outcome.ENCRYPTED);
}
@@ -42,7 +42,8 @@ class MeteredSecureMailSenderTest {
assertThatThrownBy(() -> new MeteredSecureMailSender(failing, registry).send(new SimpleMailMessage()))
.isInstanceOf(EncryptionPolicyException.class);
assertThat(registry.get(MeteredSecureMailSender.REFUSED).counter().count()).isEqualTo(1);
assertThat(registry.get(MeteredSecureMailSender.REFUSED).tag("cause", "encryption_policy").counter().count())
.isEqualTo(1);
}
@Test
@@ -55,7 +56,7 @@ class MeteredSecureMailSenderTest {
private double count(String outcome, String reason) {
return registry.get(MeteredSecureMailSender.RECIPIENTS)
.tag("outcome", outcome).tag("policy", "opportunistic").tag("reason", reason)
.tag("outcome", outcome).tag("policy", "opportunistic").tag("reason", reason).tag("signature", "none")
.counter().count();
}
@@ -0,0 +1,218 @@
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.security.KeyStore;
import java.security.cert.Certificate;
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.Multipart;
import jakarta.mail.internet.MimeBodyPart;
import jakarta.mail.internet.MimeMessage;
import pro.thinhha.mailsender.pgp.autoconfigure.PgpMailAutoConfiguration;
import pro.thinhha.mailsender.signing.MailSigningException;
import pro.thinhha.mailsender.signing.SignatureType;
import pro.thinhha.mailsender.signing.SigningTestSupport;
/**
* Signing end to end: auto-configured {@link SecureMailSender} → SMTP (GreenMail) → verification on the received
* bytes, for plaintext and encrypted copies.
*/
class SigningIntegrationTest {
private static final PGPainless API = PGPainless.getInstance();
private static final OpenPGPKey APP = API.generateKey().modernKeyRing("App <app@example.com>", "app-pass");
private static final OpenPGPKey ALICE = API.generateKey().modernKeyRing("<alice@example.org>");
@TempDir
Path dir;
private GreenMail greenMail;
private ApplicationContextRunner runner;
@BeforeEach
void setUp() throws Exception {
greenMail = new GreenMail(ServerSetupTest.SMTP.dynamicPort());
greenMail.start();
Path alice = dir.resolve("alice.asc");
Files.writeString(alice, ALICE.toCertificate().toAsciiArmoredString());
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:" + alice);
}
@AfterEach
void tearDown() {
greenMail.stop();
}
@Test
void openPgpSignsPlaintextAndEncryptedCopies() throws Exception {
Path key = dir.resolve("app.sec.asc");
Files.writeString(key, APP.toAsciiArmoredString());
runner.withPropertyValues(
"mail.pgp.signing.type=OPENPGP",
"mail.pgp.signing.openpgp[app@example.com].key=file:" + key,
"mail.pgp.signing.openpgp[app@example.com].passphrase=app-pass")
.run(context -> {
SendReport report = context.getBean(SecureMailSender.class).send(mixedMessage());
assertThat(report.signature()).isEqualTo(SignatureType.OPENPGP);
MimeMessage carol = inbox("carol@example.org");
assertThat(SigningTestSupport.verifyOpenPgp(carol, APP.toCertificate())).isTrue();
MimeMessage alice = inbox("alice@example.org");
assertThat(alice.isMimeType("multipart/encrypted")).isTrue();
byte[] armored = ((Multipart) alice.getContent()).getBodyPart(1).getInputStream().readAllBytes();
DecryptionStream in = API.processMessage().onInputStream(new ByteArrayInputStream(armored))
.withOptions(ConsumerOptions.get(API).addDecryptionKey(ALICE)
.addVerificationCert(APP.toCertificate()));
ByteArrayOutputStream inner = new ByteArrayOutputStream();
in.transferTo(inner);
in.close();
assertThat(in.getMetadata().isVerifiedSignedBy(APP.toCertificate())).isTrue();
assertThat(inner.toString()).contains("hello");
});
}
@Test
void smimeSignsPlaintextAndEncryptedCopies() throws Exception {
SigningTestSupport.SmimeIdentity identity = SigningTestSupport.smimeIdentity("app@example.com");
Path p12 = dir.resolve("app.p12");
KeyStore store = KeyStore.getInstance("PKCS12");
store.load(null, null);
store.setKeyEntry("app", identity.keyPair().getPrivate(), "pw".toCharArray(),
new Certificate[] {identity.certificate()});
try (var out = Files.newOutputStream(p12)) {
store.store(out, "pw".toCharArray());
}
runner.withPropertyValues(
"mail.pgp.signing.type=SMIME",
"mail.pgp.signing.smime[app@example.com].keystore=file:" + p12,
"mail.pgp.signing.smime[app@example.com].password=pw")
.run(context -> {
SendReport report = context.getBean(SecureMailSender.class).send(mixedMessage());
assertThat(report.signature()).isEqualTo(SignatureType.SMIME);
assertThat(SigningTestSupport.verifySmime(inbox("carol@example.org"), identity.certificate()))
.isTrue();
// encrypted copy: PGP/MIME around an S/MIME-signed entity
MimeMessage alice = inbox("alice@example.org");
byte[] armored = ((Multipart) alice.getContent()).getBodyPart(1).getInputStream().readAllBytes();
ByteArrayOutputStream inner = new ByteArrayOutputStream();
try (DecryptionStream in = API.processMessage().onInputStream(new ByteArrayInputStream(armored))
.withOptions(ConsumerOptions.get(API).addDecryptionKey(ALICE))) {
in.transferTo(inner);
}
MimeMessage decrypted = new MimeMessage(SigningTestSupport.SESSION,
new ByteArrayInputStream(inner.toByteArray()));
assertThat(SigningTestSupport.verifySmime(decrypted, identity.certificate())).isTrue();
MimeBodyPart content = (MimeBodyPart) ((Multipart) decrypted.getContent()).getBodyPart(0);
assertThat(content.getContent().toString()).contains("hello");
});
}
@Test
void requiredSigningRefusesSenderWithoutKey() throws Exception {
Path key = dir.resolve("app.sec.asc");
Files.writeString(key, APP.toAsciiArmoredString());
runner.withPropertyValues(
"mail.pgp.signing.type=OPENPGP",
"mail.pgp.signing.required=true",
"mail.pgp.signing.openpgp[app@example.com].key=file:" + key,
"mail.pgp.signing.openpgp[app@example.com].passphrase=app-pass")
.run(context -> {
SimpleMailMessage message = new SimpleMailMessage();
message.setFrom("other@example.com");
message.setTo("carol@example.org");
message.setText("x");
assertThatThrownBy(() -> context.getBean(SecureMailSender.class).send(message))
.isInstanceOf(MailSigningException.class).hasMessageContaining("other@example.com");
assertThat(greenMail.getReceivedMessages()).isEmpty();
});
}
@Test
void relayModeSignsBeforeHandingOver() throws Exception {
Path key = dir.resolve("app.sec.asc");
Files.writeString(key, APP.toAsciiArmoredString());
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.signing.type=OPENPGP",
"mail.pgp.signing.openpgp[app@example.com].key=file:" + key,
"mail.pgp.signing.openpgp[app@example.com].passphrase=app-pass")
.run(context -> {
SendReport report = context.getBean(SecureMailSender.class).send(mixedMessage());
assertThat(report.signature()).isEqualTo(SignatureType.OPENPGP);
assertThat(SigningTestSupport.verifyOpenPgp(inbox("carol@example.org"), APP.toCertificate()))
.isTrue();
});
}
@Test
void startupFailsOnWrongPassphraseOrMissingKeys() throws Exception {
Path key = dir.resolve("app.sec.asc");
Files.writeString(key, APP.toAsciiArmoredString());
runner.withPropertyValues(
"mail.pgp.signing.type=OPENPGP",
"mail.pgp.signing.openpgp[app@example.com].key=file:" + key,
"mail.pgp.signing.openpgp[app@example.com].passphrase=wrong")
.run(context -> assertThat(context).hasFailed().getFailure()
.hasStackTraceContaining("wrong passphrase?"));
runner.withPropertyValues("mail.pgp.signing.type=SMIME")
.run(context -> assertThat(context).hasFailed().getFailure()
.rootCause().hasMessageContaining("no mail.pgp.signing.smime[address] key"));
}
private static SimpleMailMessage mixedMessage() {
SimpleMailMessage message = new SimpleMailMessage();
message.setFrom("app@example.com");
message.setTo("alice@example.org", "carol@example.org");
message.setSubject("Signed");
message.setText("hello, Grüße");
return message;
}
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();
}
}