mirror of
https://github.com/sneakybugmaster/mailsender.git
synced 2026-11-10 19:48:49 +00:00
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:
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>
|
||||
|
||||
+66
-4
@@ -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)));
|
||||
|
||||
+39
@@ -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.
|
||||
|
||||
+15
-7
@@ -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) {
|
||||
|
||||
+5
-4
@@ -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()),
|
||||
|
||||
+25
-1
@@ -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,
|
||||
|
||||
+13
-2
@@ -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);
|
||||
}
|
||||
|
||||
+3
-2
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
+218
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user