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

+31 -1
View File
@@ -14,7 +14,7 @@ Requires JDK 25 or newer; the Maven Wrapper (`./mvnw`) downloads Maven 3.9.11. B
| Module | Contents | | Module | Contents |
|---|---| |---|---|
| `mailsender-pgp-core` | WKD key discovery, caching, pinned keys, certificate validation, OpenPGP encryption, PGP/MIME (no Spring) | | `mailsender-pgp-core` | WKD key discovery, caching, pinned keys, certificate validation, OpenPGP encryption, PGP/MIME, OpenPGP and S/MIME signing (no Spring) |
| `mailsender-pgp-spring-boot-autoconfigure` | `mail.pgp.*` properties and beans | | `mailsender-pgp-spring-boot-autoconfigure` | `mail.pgp.*` properties and beans |
| `mailsender-pgp-spring-boot-starter` | dependency bundle for applications | | `mailsender-pgp-spring-boot-starter` | dependency bundle for applications |
| `mailsender-demo` | sample app that sends test mails | | `mailsender-demo` | sample app that sends test mails |
@@ -75,6 +75,36 @@ Map keys that are addresses need the `[...]` brackets. See [docs/PLAN.md](docs/P
- With Actuator, expose `pgpkeys` to inspect an address (`GET /actuator/pgpkeys/{email}`) or evict cached keys - With Actuator, expose `pgpkeys` to inspect an address (`GET /actuator/pgpkeys/{email}`) or evict cached keys
(`DELETE /actuator/pgpkeys/{email}`). (`DELETE /actuator/pgpkeys/{email}`).
## Signing
Outgoing mail can be signed with the From address's OpenPGP key or S/MIME certificate:
```yaml
mail:
pgp:
signing:
type: OPENPGP # NONE (default) | OPENPGP | SMIME
required: false # true: refuse mail from an address without a signing key
openpgp:
"[noreply@example.com]":
key: file:/etc/mail/noreply.sec.asc # secret key
passphrase: ${SIGNING_PASSPHRASE}
smime:
"[noreply@example.com]":
keystore: file:/etc/mail/noreply.p12 # PKCS#12: private key + certificate chain
password: ${SMIME_PASSWORD}
```
| | Plaintext copies | Encrypted copies |
|---|---|---|
| `OPENPGP` | PGP/MIME `multipart/signed` (RFC 3156) | signed inside the encryption |
| `SMIME` | S/MIME `multipart/signed` (RFC 8551) | S/MIME-signed, then PGP/MIME-encrypted |
Keys are checked at startup: the key/certificate must belong to the address, be able to sign and be currently valid,
and the passphrase must work. `SendReport.signature()` says how a message was signed. In RELAY mode the message is
signed before it is handed to the gateway. Do not enable `mail.smtp.allow8bitmime` together with signing: re-encoding
the body in transit invalidates signatures.
## Demo app ## Demo app
The demo app sends `DEMO_COUNT` test mails (default: 1 to `test@thinhha.pro`) through `SecureMailSender` and logs The demo app sends `DEMO_COUNT` test mails (default: 1 to `test@thinhha.pro`) through `SecureMailSender` and logs
+18
View File
@@ -172,6 +172,23 @@ Actuator is optional; when the `pgpkeys` endpoint is exposed
Extension points: `KeyDiscoveryCustomizer` (wraps WKD discovery below the cache) and `SecureMailSenderCustomizer`. Extension points: `KeyDiscoveryCustomizer` (wraps WKD discovery below the cache) and `SecureMailSenderCustomizer`.
## Signing (M9)
`mail.pgp.signing.type`: `NONE` (default), `OPENPGP` or `SMIME`; one key per From address.
- **OPENPGP** — plaintext copies: `multipart/signed; protocol="application/pgp-signature"; micalg=pgp-<hash>`, detached
signature over the exact bytes of the content entity (RFC 3156 section 5). Encrypted copies: signature inside the
encryption (RFC 3156 section 6.2, combined method).
- **SMIME** — plaintext copies: `multipart/signed; protocol="application/pkcs7-signature"` with the certificate chain
(Bouncy Castle `bcjmail`, RFC 8551). Encrypted copies: the S/MIME-signed entity is PGP/MIME-encrypted.
- Signed bodies are attached as raw bytes and the signed part is a parsed MIME part, so JavaMail writes them back byte
for byte; tests verify signatures on the bytes received over SMTP, and the output was checked with `gpg --verify` and
`openssl smime -verify`.
- Keys are validated at startup (can sign, names the address, currently valid, passphrase/private key works).
- `required=true` refuses mail from an address without a key (`MailSigningException`, metric
`mail.pgp.refused{cause=signing_required}`); otherwise it is sent unsigned with a warning.
- RELAY mode signs before handing the message to the gateway.
## Milestones ## Milestones
1. **Skeleton** — parent POM, modules, CI (GitHub Actions, `mvn verify` on Temurin 25). ✅ 1. **Skeleton** — parent POM, modules, CI (GitHub Actions, `mvn verify` on Temurin 25). ✅
2. **WKD discovery + cache** — `WkdKeyDiscovery`, `HttpWkdFetcher`, `CachingKeyDiscovery`, static/composite discovery, 2. **WKD discovery + cache** — `WkdKeyDiscovery`, `HttpWkdFetcher`, `CachingKeyDiscovery`, static/composite discovery,
@@ -193,3 +210,4 @@ Extension points: `KeyDiscoveryCustomizer` (wraps WKD discovery below the cache)
`test@thinhha.pro` → found via advanced method, fpr `ADA6EFA0CC1630D602889820112495670FF1B4F0`. `test@thinhha.pro` → found via advanced method, fpr `ADA6EFA0CC1630D602889820112495670FF1B4F0`.
8. **Hardening** — Micrometer metrics, protected headers (RFC 9788, opt-in), actuator endpoint, docs. ✅ 8. **Hardening** — Micrometer metrics, protected headers (RFC 9788, opt-in), actuator endpoint, docs. ✅
Encrypting no longer regenerates the caller's Message-ID. Encrypting no longer regenerates the caller's Message-ID.
9. **Signing** — OpenPGP and S/MIME signing, configurable per application. ✅
@@ -24,6 +24,17 @@ mail:
# Readable sent copies: the sender's own PUBLIC key per From address. # Readable sent copies: the sender's own PUBLIC key per From address.
# sender-keys: # sender-keys:
# "[noreply@example.com]": file:/etc/mail/noreply.pub.asc # "[noreply@example.com]": file:/etc/mail/noreply.pub.asc
# Signing with the From address's key: NONE | OPENPGP | SMIME
signing:
type: ${MAIL_PGP_SIGNING_TYPE:NONE}
# openpgp:
# "[noreply@example.com]":
# key: file:/etc/mail/noreply.sec.asc
# passphrase: ${SIGNING_PASSPHRASE}
# smime:
# "[noreply@example.com]":
# keystore: file:/etc/mail/noreply.p12
# password: ${SMIME_PASSWORD}
relay: relay:
host: ${RELAY_HOST:} host: ${RELAY_HOST:}
port: ${RELAY_PORT:587} port: ${RELAY_PORT:587}
+26 -1
View File
@@ -11,7 +11,7 @@
</parent> </parent>
<artifactId>mailsender-pgp-core</artifactId> <artifactId>mailsender-pgp-core</artifactId>
<description>WKD key discovery, validation, OpenPGP encryption and PGP/MIME building (no Spring)</description> <description>WKD key discovery, validation, OpenPGP encryption, PGP/MIME and S/MIME signing (no Spring)</description>
<dependencies> <dependencies>
<dependency> <dependency>
@@ -22,6 +22,15 @@
<groupId>org.pgpainless</groupId> <groupId>org.pgpainless</groupId>
<artifactId>wkd-java</artifactId> <artifactId>wkd-java</artifactId>
</dependency> </dependency>
<dependency>
<!-- S/MIME signing -->
<groupId>org.bouncycastle</groupId>
<artifactId>bcpkix-jdk18on</artifactId>
</dependency>
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcjmail-jdk18on</artifactId>
</dependency>
<dependency> <dependency>
<groupId>com.github.ben-manes.caffeine</groupId> <groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId> <artifactId>caffeine</artifactId>
@@ -52,4 +61,20 @@
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
</dependencies> </dependencies>
<build>
<plugins>
<plugin>
<!-- test helpers (keys, certificates, signature verification) for the other modules' tests -->
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<executions>
<execution>
<goals>
<goal>test-jar</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project> </project>
@@ -0,0 +1,23 @@
package pro.thinhha.mailsender.pgp.crypto;
import java.util.Objects;
import org.bouncycastle.openpgp.api.OpenPGPKey;
import org.pgpainless.key.protection.SecretKeyRingProtector;
import org.pgpainless.util.Passphrase;
/** A secret OpenPGP key plus what unlocks it. */
public record OpenPgpSigningKey(OpenPGPKey key, SecretKeyRingProtector protector) {
public OpenPgpSigningKey {
Objects.requireNonNull(key, "key");
Objects.requireNonNull(protector, "protector");
}
/** @param passphrase {@code null} or empty for an unprotected key */
public static OpenPgpSigningKey of(OpenPGPKey key, char[] passphrase) {
return new OpenPgpSigningKey(key, passphrase == null || passphrase.length == 0
? SecretKeyRingProtector.unprotectedKeys()
: SecretKeyRingProtector.unlockAnyKeyWith(Passphrase.fromPassword(new String(passphrase))));
}
}
@@ -8,9 +8,11 @@ import java.util.Objects;
import org.bouncycastle.openpgp.PGPException; import org.bouncycastle.openpgp.PGPException;
import org.bouncycastle.openpgp.api.OpenPGPCertificate; import org.bouncycastle.openpgp.api.OpenPGPCertificate;
import org.pgpainless.PGPainless; import org.pgpainless.PGPainless;
import org.pgpainless.algorithm.DocumentSignatureType;
import org.pgpainless.encryption_signing.EncryptionOptions; import org.pgpainless.encryption_signing.EncryptionOptions;
import org.pgpainless.encryption_signing.EncryptionStream; import org.pgpainless.encryption_signing.EncryptionStream;
import org.pgpainless.encryption_signing.ProducerOptions; import org.pgpainless.encryption_signing.ProducerOptions;
import org.pgpainless.encryption_signing.SigningOptions;
/** Encrypts data to OpenPGP certificates and returns the ASCII-armored message. */ /** Encrypts data to OpenPGP certificates and returns the ASCII-armored message. */
public final class PgpEncryptor { public final class PgpEncryptor {
@@ -34,22 +36,35 @@ public final class PgpEncryptor {
*/ */
public byte[] encrypt(byte[] plaintext, Collection<OpenPGPCertificate> recipients, public byte[] encrypt(byte[] plaintext, Collection<OpenPGPCertificate> recipients,
Collection<OpenPGPCertificate> hiddenRecipients) throws IOException { Collection<OpenPGPCertificate> hiddenRecipients) throws IOException {
return encrypt(plaintext, recipients, hiddenRecipients, null);
}
/**
* @param signingKey if not {@code null}, the message is also signed with this key; the signature is inside the
* encryption (RFC 3156 section 6.2, combined method)
*/
public byte[] encrypt(byte[] plaintext, Collection<OpenPGPCertificate> recipients,
Collection<OpenPGPCertificate> hiddenRecipients, OpenPgpSigningKey signingKey) throws IOException {
if (recipients.isEmpty() && hiddenRecipients.isEmpty()) { if (recipients.isEmpty() && hiddenRecipients.isEmpty()) {
throw new IllegalArgumentException("At least one recipient is required"); throw new IllegalArgumentException("At least one recipient is required");
} }
EncryptionOptions options = EncryptionOptions.encryptCommunications(api); EncryptionOptions options = EncryptionOptions.encryptCommunications(api);
recipients.forEach(options::addRecipient); recipients.forEach(options::addRecipient);
hiddenRecipients.forEach(options::addHiddenRecipient); hiddenRecipients.forEach(options::addHiddenRecipient);
ProducerOptions producerOptions = signingKey == null
? ProducerOptions.encrypt(options)
: ProducerOptions.signAndEncrypt(options, SigningOptions.get(api).addInlineSignature(
signingKey.protector(), signingKey.key(), DocumentSignatureType.BINARY_DOCUMENT));
ByteArrayOutputStream out = new ByteArrayOutputStream(plaintext.length + 1024); ByteArrayOutputStream out = new ByteArrayOutputStream(plaintext.length + 1024);
try (EncryptionStream stream = api.generateMessage() try (EncryptionStream stream = api.generateMessage()
.onOutputStream(out) .onOutputStream(out)
.withOptions(ProducerOptions.encrypt(options) .withOptions(producerOptions
.setAsciiArmor(true) .setAsciiArmor(true)
.setHideArmorHeaders(true))) { .setHideArmorHeaders(true))) {
stream.write(plaintext); stream.write(plaintext);
} catch (PGPException e) { } catch (PGPException | RuntimeException e) {
throw new IOException("OpenPGP encryption failed", e); throw new IOException("OpenPGP encryption failed: " + e.getMessage(), e);
} }
return out.toByteArray(); return out.toByteArray();
} }
@@ -0,0 +1,74 @@
package pro.thinhha.mailsender.pgp.crypto;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
import org.bouncycastle.bcpg.HashAlgorithmTags;
import org.bouncycastle.openpgp.PGPException;
import org.bouncycastle.openpgp.PGPSignature;
import org.pgpainless.PGPainless;
import org.pgpainless.algorithm.DocumentSignatureType;
import org.pgpainless.encryption_signing.EncryptionStream;
import org.pgpainless.encryption_signing.ProducerOptions;
import org.pgpainless.encryption_signing.SigningOptions;
/** Creates detached OpenPGP signatures, as used by PGP/MIME {@code multipart/signed} (RFC 3156 section 5). */
public final class PgpSigner {
/**
* @param armored ASCII-armored signature
* @param micalg the {@code micalg} parameter for {@code multipart/signed}, e.g. {@code pgp-sha512}
*/
public record DetachedSignature(byte[] armored, String micalg) {
}
private final PGPainless api;
public PgpSigner() {
this(PGPainless.getInstance());
}
public PgpSigner(PGPainless api) {
this.api = Objects.requireNonNull(api, "api");
}
/** Signs {@code data}, which must already be in canonical (CRLF) form. */
public DetachedSignature sign(byte[] data, OpenPgpSigningKey signingKey) throws IOException {
EncryptionStream stream;
try {
stream = api.generateMessage()
.onOutputStream(OutputStream.nullOutputStream())
.withOptions(ProducerOptions.sign(SigningOptions.get(api).addDetachedSignature(
signingKey.protector(), signingKey.key(), DocumentSignatureType.CANONICAL_TEXT_DOCUMENT)));
stream.write(data);
stream.close();
} catch (PGPException | RuntimeException e) {
throw new IOException("OpenPGP signing failed: " + e.getMessage(), e);
}
Set<PGPSignature> signatures = stream.getResult().getDetachedSignatures().flatten();
if (signatures.size() != 1) {
throw new IOException("Expected one signature, got " + signatures.size());
}
PGPSignature signature = signatures.iterator().next();
String armored = api.toAsciiArmor(signature);
return new DetachedSignature(armored.getBytes(StandardCharsets.US_ASCII), micalg(signature.getHashAlgorithm()));
}
static String micalg(int hashAlgorithm) {
String name = switch (hashAlgorithm) {
case HashAlgorithmTags.SHA1 -> "sha1";
case HashAlgorithmTags.SHA224 -> "sha224";
case HashAlgorithmTags.SHA256 -> "sha256";
case HashAlgorithmTags.SHA384 -> "sha384";
case HashAlgorithmTags.SHA512 -> "sha512";
case HashAlgorithmTags.SHA3_256 -> "sha3-256";
case HashAlgorithmTags.SHA3_512 -> "sha3-512";
default -> throw new IllegalArgumentException("Unsupported hash algorithm " + hashAlgorithm);
};
return "pgp-" + name.toLowerCase(Locale.ROOT);
}
}
@@ -5,6 +5,7 @@ import java.util.ArrayList;
import java.util.Date; import java.util.Date;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
import java.util.Optional;
import java.util.function.Supplier; import java.util.function.Supplier;
import org.bouncycastle.openpgp.api.OpenPGPCertificate; import org.bouncycastle.openpgp.api.OpenPGPCertificate;
@@ -14,24 +15,49 @@ import jakarta.mail.MessagingException;
import jakarta.mail.internet.InternetAddress; import jakarta.mail.internet.InternetAddress;
import jakarta.mail.internet.MimeMessage; import jakarta.mail.internet.MimeMessage;
import pro.thinhha.mailsender.pgp.mime.PgpMimeEncryptor; import pro.thinhha.mailsender.pgp.mime.PgpMimeEncryptor;
import pro.thinhha.mailsender.signing.MailSigning;
import pro.thinhha.mailsender.signing.MessageSigner;
import pro.thinhha.mailsender.signing.OpenPgpMessageSigner;
import pro.thinhha.mailsender.signing.SignatureType;
import pro.thinhha.mailsender.signing.SmimeMessageSigner;
/** Builds the concrete messages for a {@link DeliveryPlan}. */ /**
* Builds the concrete messages for a {@link DeliveryPlan}. With a signer for the From address:
* <ul>
* <li>plaintext copies are {@code multipart/signed} (OpenPGP or S/MIME)</li>
* <li>encrypted copies are signed inside the encryption (OpenPGP), or S/MIME-signed and then encrypted</li>
* </ul>
*/
public final class MessageAssembler { public final class MessageAssembler {
/** The messages to send and how they were signed. */
public record Assembly(List<MimeMessage> messages, SignatureType signature) {
public Assembly {
messages = List.copyOf(messages);
}
}
private final PgpMimeEncryptor mimeEncryptor; private final PgpMimeEncryptor mimeEncryptor;
private final SenderKeyProvider senderKeys; private final SenderKeyProvider senderKeys;
private final MailSigning signing;
public MessageAssembler(PgpMimeEncryptor mimeEncryptor, SenderKeyProvider senderKeys) { public MessageAssembler(PgpMimeEncryptor mimeEncryptor, SenderKeyProvider senderKeys) {
this(mimeEncryptor, senderKeys, MailSigning.disabled());
}
public MessageAssembler(PgpMimeEncryptor mimeEncryptor, SenderKeyProvider senderKeys, MailSigning signing) {
this.mimeEncryptor = Objects.requireNonNull(mimeEncryptor, "mimeEncryptor"); this.mimeEncryptor = Objects.requireNonNull(mimeEncryptor, "mimeEncryptor");
this.senderKeys = Objects.requireNonNull(senderKeys, "senderKeys"); this.senderKeys = Objects.requireNonNull(senderKeys, "senderKeys");
this.signing = Objects.requireNonNull(signing, "signing");
} }
/** /**
* @param newMessage creates empty messages bound to the sending session, e.g. * @param newMessage creates empty messages bound to the sending session, e.g.
* {@code JavaMailSender::createMimeMessage} * {@code JavaMailSender::createMimeMessage}
*/ */
public List<MimeMessage> assemble(MimeMessage original, DeliveryPlan plan, Supplier<MimeMessage> newMessage) public Assembly assemble(MimeMessage original, DeliveryPlan plan, Supplier<MimeMessage> newMessage)
throws MessagingException, IOException { throws MessagingException, IOException {
Optional<MessageSigner> signer = signing.signerFor(original);
List<OpenPGPCertificate> selfKeys = senderKey(original); List<OpenPGPCertificate> selfKeys = senderKey(original);
// All copies are the same message to their readers: same Date and Message-ID. // All copies are the same message to their readers: same Date and Message-ID.
if (original.getSentDate() == null) { if (original.getSentDate() == null) {
@@ -40,20 +66,34 @@ public final class MessageAssembler {
original.saveChanges(); original.saveChanges();
String messageId = original.getMessageID(); String messageId = original.getMessageID();
MimeMessage signedOriginal = null;
List<MimeMessage> messages = new ArrayList<>(); List<MimeMessage> messages = new ArrayList<>();
for (Delivery delivery : plan.deliveries()) { for (Delivery delivery : plan.deliveries()) {
MimeMessage message = original; MimeMessage message;
if (delivery.encrypted()) { if (delivery.encrypted()) {
List<OpenPGPCertificate> visible = new ArrayList<>(selfKeys); List<OpenPGPCertificate> visible = new ArrayList<>(selfKeys);
List<OpenPGPCertificate> hidden = new ArrayList<>(); List<OpenPGPCertificate> hidden = new ArrayList<>();
(delivery.hiddenRecipients() ? hidden : visible).addAll(delivery.recipientKeys()); (delivery.hiddenRecipients() ? hidden : visible).addAll(delivery.recipientKeys());
message = mimeEncryptor.encrypt(original, visible, hidden, newMessage); message = switch (signer.orElse(null)) {
case OpenPgpMessageSigner openPgp ->
mimeEncryptor.encrypt(original, visible, hidden, newMessage, openPgp.signingKey());
case SmimeMessageSigner smime -> {
signedOriginal = signedOriginal != null ? signedOriginal : smime.sign(original, newMessage);
yield mimeEncryptor.encrypt(signedOriginal, visible, hidden, newMessage);
}
case null -> mimeEncryptor.encrypt(original, visible, hidden, newMessage);
};
} else if (signer.isPresent()) {
signedOriginal = signedOriginal != null ? signedOriginal : signer.get().sign(original, newMessage);
message = signedOriginal;
} else {
message = original;
} }
EnvelopeMimeMessage copy = new EnvelopeMimeMessage(message, delivery.envelope()); EnvelopeMimeMessage copy = new EnvelopeMimeMessage(message, delivery.envelope());
copy.setHeader("Message-ID", messageId); copy.setHeader("Message-ID", messageId);
messages.add(copy); messages.add(copy);
} }
return messages; return new Assembly(messages, signer.map(MessageSigner::type).orElse(SignatureType.NONE));
} }
private List<OpenPGPCertificate> senderKey(MimeMessage original) throws MessagingException { private List<OpenPGPCertificate> senderKey(MimeMessage original) throws MessagingException {
@@ -0,0 +1,143 @@
package pro.thinhha.mailsender.pgp.mime;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.Enumeration;
import java.util.List;
import java.util.Locale;
import java.util.Properties;
import java.util.UUID;
import java.util.function.Supplier;
import jakarta.activation.DataHandler;
import jakarta.mail.Header;
import jakarta.mail.MessagingException;
import jakarta.mail.Session;
import jakarta.mail.internet.ContentType;
import jakarta.mail.internet.MimeMessage;
import jakarta.mail.util.ByteArrayDataSource;
/**
* Byte-level helpers shared by PGP/MIME encryption and multipart/signed signing. Signatures cover exact bytes, so
* entities are taken from a serialized snapshot and signed/encrypted bodies are attached as raw bytes rather than
* rebuilt by JavaMail.
*/
public final class MimeEntities {
static final Session PARSE_SESSION = Session.getInstance(new Properties());
private static final List<String> PROTECTED_HEADERS =
List.of("From", "To", "Cc", "Reply-To", "Date", "Message-ID", "Subject");
private MimeEntities() {
}
/**
* Serializes and re-parses the message so headers are final and the encoded body is available. Keeps the
* caller's Message-ID, which {@code saveChanges()} would otherwise regenerate.
*/
public static MimeMessage snapshot(MimeMessage original) throws MessagingException, IOException {
String messageId = original.getMessageID();
original.saveChanges();
if (messageId != null) {
original.setHeader("Message-ID", messageId);
}
return parse(serialize(original));
}
public static byte[] serialize(MimeMessage message) throws MessagingException, IOException {
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
message.writeTo(buffer);
return buffer.toByteArray();
}
public static MimeMessage parse(byte[] message) throws MessagingException {
return new MimeMessage(PARSE_SESSION, new ByteArrayInputStream(message));
}
/**
* The message's content as a standalone MIME entity: its {@code Content-*} headers (plus the protected headers,
* if requested) and the still-encoded body, with CRLF line endings.
*/
public static byte[] contentEntity(MimeMessage source, boolean protectHeaders)
throws MessagingException, IOException {
ByteArrayOutputStream entity = new ByteArrayOutputStream();
if (protectHeaders) {
for (String name : PROTECTED_HEADERS) {
String[] values = source.getHeader(name);
if (values != null) {
for (String value : values) {
writeHeader(entity, name + ": " + value);
}
}
}
}
Enumeration<String> lines = source.getAllHeaderLines();
while (lines.hasMoreElements()) {
String line = lines.nextElement();
if (protectHeaders && line.regionMatches(true, 0, "Content-Type:", 0, "Content-Type:".length())) {
ContentType type = new ContentType(source.getContentType());
type.setParameter("protected-headers", "v1");
writeHeader(entity, "Content-Type: " + type);
} else if (line.regionMatches(true, 0, "content-", 0, "content-".length())) {
writeHeader(entity, line);
}
}
entity.write('\r');
entity.write('\n');
try (InputStream body = source.getRawInputStream()) {
body.transferTo(entity);
}
return toCrlf(entity.toByteArray());
}
/** Copies all headers except {@code Content-*}, MIME-Version and Message-ID. */
public static void copyEnvelopeHeaders(MimeMessage source, MimeMessage target) throws MessagingException {
Enumeration<Header> headers = source.getAllHeaders();
while (headers.hasMoreElements()) {
Header header = headers.nextElement();
String name = header.getName().toLowerCase(Locale.ROOT);
if (!name.startsWith("content-") && !name.equals("mime-version") && !name.equals("message-id")) {
target.addHeader(header.getName(), header.getValue());
}
}
}
/**
* A message with {@code source}'s envelope headers whose body is exactly {@code body}. JavaMail writes such a
* body unchanged, so a signature over a part inside it stays valid.
*/
public static MimeMessage withRawBody(MimeMessage source, String contentType, byte[] body,
Supplier<MimeMessage> newMessage) throws MessagingException {
MimeMessage message = newMessage.get();
copyEnvelopeHeaders(source, message);
message.setDataHandler(new DataHandler(new ByteArrayDataSource(body, contentType)));
message.saveChanges();
return message;
}
public static String newBoundary(String prefix) {
return "----=_" + prefix + "_" + UUID.randomUUID().toString().replace("-", "");
}
/** Every line ends in CRLF (canonical form, RFC 3156 and RFC 8551). */
public static byte[] toCrlf(byte[] data) {
ByteArrayOutputStream out = new ByteArrayOutputStream(data.length + data.length / 50);
for (int i = 0; i < data.length; i++) {
byte b = data[i];
if (b == '\n' && (i == 0 || data[i - 1] != '\r')) {
out.write('\r');
}
out.write(b);
}
return out.toByteArray();
}
private static void writeHeader(ByteArrayOutputStream entity, String line) throws IOException {
entity.write(line.getBytes(StandardCharsets.UTF_8));
entity.write('\r');
entity.write('\n');
}
}
@@ -1,29 +1,20 @@
package pro.thinhha.mailsender.pgp.mime; package pro.thinhha.mailsender.pgp.mime;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.Collection; import java.util.Collection;
import java.util.Enumeration;
import java.util.List;
import java.util.Locale;
import java.util.Objects; import java.util.Objects;
import java.util.Properties;
import java.util.function.Supplier; import java.util.function.Supplier;
import org.bouncycastle.openpgp.api.OpenPGPCertificate; import org.bouncycastle.openpgp.api.OpenPGPCertificate;
import pro.thinhha.mailsender.pgp.crypto.PgpEncryptor;
import jakarta.activation.DataHandler; import jakarta.activation.DataHandler;
import jakarta.mail.Header;
import jakarta.mail.MessagingException; import jakarta.mail.MessagingException;
import jakarta.mail.Session;
import jakarta.mail.internet.ContentType;
import jakarta.mail.internet.MimeBodyPart; import jakarta.mail.internet.MimeBodyPart;
import jakarta.mail.internet.MimeMessage; import jakarta.mail.internet.MimeMessage;
import jakarta.mail.util.ByteArrayDataSource; import jakarta.mail.util.ByteArrayDataSource;
import pro.thinhha.mailsender.pgp.crypto.OpenPgpSigningKey;
import pro.thinhha.mailsender.pgp.crypto.PgpEncryptor;
/** /**
* Turns a ready-to-send {@link MimeMessage} into a PGP/MIME message (RFC 3156). * Turns a ready-to-send {@link MimeMessage} into a PGP/MIME message (RFC 3156).
@@ -37,11 +28,8 @@ import jakarta.mail.util.ByteArrayDataSource;
*/ */
public final class PgpMimeEncryptor { public final class PgpMimeEncryptor {
private static final Session PARSE_SESSION = Session.getInstance(new Properties());
private static final byte[] VERSION_PART = "Version: 1\r\n".getBytes(StandardCharsets.US_ASCII); private static final byte[] VERSION_PART = "Version: 1\r\n".getBytes(StandardCharsets.US_ASCII);
public static final String OBSCURED_SUBJECT = "[...]"; public static final String OBSCURED_SUBJECT = "[...]";
private static final List<String> PROTECTED_HEADERS =
List.of("From", "To", "Cc", "Reply-To", "Date", "Message-ID", "Subject");
private final PgpEncryptor encryptor; private final PgpEncryptor encryptor;
private final boolean protectHeaders; private final boolean protectHeaders;
@@ -65,11 +53,19 @@ public final class PgpMimeEncryptor {
public MimeMessage encrypt(MimeMessage original, Collection<OpenPGPCertificate> recipients, public MimeMessage encrypt(MimeMessage original, Collection<OpenPGPCertificate> recipients,
Collection<OpenPGPCertificate> hiddenRecipients, Supplier<MimeMessage> newMessage) Collection<OpenPGPCertificate> hiddenRecipients, Supplier<MimeMessage> newMessage)
throws MessagingException, IOException { throws MessagingException, IOException {
MimeMessage source = snapshot(original); return encrypt(original, recipients, hiddenRecipients, newMessage, null);
byte[] ciphertext = encryptor.encrypt(innerEntity(source, protectHeaders), recipients, hiddenRecipients); }
/** @param signingKey if not {@code null}, also sign inside the encryption */
public MimeMessage encrypt(MimeMessage original, Collection<OpenPGPCertificate> recipients,
Collection<OpenPGPCertificate> hiddenRecipients, Supplier<MimeMessage> newMessage,
OpenPgpSigningKey signingKey) throws MessagingException, IOException {
MimeMessage source = MimeEntities.snapshot(original);
byte[] ciphertext = encryptor.encrypt(MimeEntities.contentEntity(source, protectHeaders), recipients,
hiddenRecipients, signingKey);
MimeMessage outer = newMessage.get(); MimeMessage outer = newMessage.get();
copyEnvelopeHeaders(source, outer); MimeEntities.copyEnvelopeHeaders(source, outer);
if (protectHeaders && outer.getHeader("Subject") != null) { if (protectHeaders && outer.getHeader("Subject") != null) {
outer.setHeader("Subject", OBSCURED_SUBJECT); outer.setHeader("Subject", OBSCURED_SUBJECT);
} }
@@ -78,73 +74,6 @@ public final class PgpMimeEncryptor {
return outer; return outer;
} }
/**
* Serializes and re-parses the message so headers are final and the encoded body is available. Keeps the
* caller's Message-ID, which {@code saveChanges()} would otherwise regenerate.
*/
private static MimeMessage snapshot(MimeMessage original) throws MessagingException, IOException {
String messageId = original.getMessageID();
original.saveChanges();
if (messageId != null) {
original.setHeader("Message-ID", messageId);
}
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
original.writeTo(buffer);
return new MimeMessage(PARSE_SESSION, new ByteArrayInputStream(buffer.toByteArray()));
}
/**
* The {@code Content-*} headers (plus the protected headers, if enabled) and the still-encoded body, with CRLF
* line endings.
*/
static byte[] innerEntity(MimeMessage source, boolean protectHeaders) throws MessagingException, IOException {
ByteArrayOutputStream entity = new ByteArrayOutputStream();
if (protectHeaders) {
for (String name : PROTECTED_HEADERS) {
String[] values = source.getHeader(name);
if (values != null) {
for (String value : values) {
writeHeader(entity, name + ": " + value);
}
}
}
}
Enumeration<String> lines = source.getAllHeaderLines();
while (lines.hasMoreElements()) {
String line = lines.nextElement();
if (protectHeaders && line.regionMatches(true, 0, "Content-Type:", 0, "Content-Type:".length())) {
ContentType type = new ContentType(source.getContentType());
type.setParameter("protected-headers", "v1");
writeHeader(entity, "Content-Type: " + type);
} else if (isContentHeader(line)) {
writeHeader(entity, line);
}
}
entity.write('\r');
entity.write('\n');
try (InputStream body = source.getRawInputStream()) {
body.transferTo(entity);
}
return toCrlf(entity.toByteArray());
}
private static void writeHeader(ByteArrayOutputStream entity, String line) throws IOException {
entity.write(line.getBytes(StandardCharsets.UTF_8));
entity.write('\r');
entity.write('\n');
}
private static void copyEnvelopeHeaders(MimeMessage source, MimeMessage outer) throws MessagingException {
Enumeration<Header> headers = source.getAllHeaders();
while (headers.hasMoreElements()) {
Header header = headers.nextElement();
String name = header.getName().toLowerCase(Locale.ROOT);
if (!name.startsWith("content-") && !name.equals("mime-version") && !name.equals("message-id")) {
outer.addHeader(header.getName(), header.getValue());
}
}
}
private static EncryptedMultipart pgpMimeBody(byte[] ciphertext) throws MessagingException { private static EncryptedMultipart pgpMimeBody(byte[] ciphertext) throws MessagingException {
MimeBodyPart control = new MimeBodyPart(); MimeBodyPart control = new MimeBodyPart();
control.setDataHandler(new DataHandler(new ByteArrayDataSource(VERSION_PART, EncryptedMultipart.PROTOCOL))); control.setDataHandler(new DataHandler(new ByteArrayDataSource(VERSION_PART, EncryptedMultipart.PROTOCOL)));
@@ -164,21 +93,4 @@ public final class PgpMimeEncryptor {
multipart.addBodyPart(encrypted); multipart.addBodyPart(encrypted);
return multipart; return multipart;
} }
private static boolean isContentHeader(String headerLine) {
return headerLine.regionMatches(true, 0, "content-", 0, "content-".length());
}
/** RFC 3156 requires the encrypted entity in canonical form: every line ends in CRLF. */
static byte[] toCrlf(byte[] data) {
ByteArrayOutputStream out = new ByteArrayOutputStream(data.length + data.length / 50);
for (int i = 0; i < data.length; i++) {
byte b = data[i];
if (b == '\n' && (i == 0 || data[i - 1] != '\r')) {
out.write('\r');
}
out.write(b);
}
return out.toByteArray();
}
} }
@@ -72,7 +72,7 @@ public final class CertificateValidator {
* Matches {@code email} against the address in each User ID, either the {@code <...>} part of * Matches {@code email} against the address in each User ID, either the {@code <...>} part of
* {@code Name <addr>} or the whole User ID. Comparison ignores case: WKD already lowercases the local part. * {@code Name <addr>} or the whole User ID. Comparison ignores case: WKD already lowercases the local part.
*/ */
static boolean hasUserIdFor(List<String> userIds, String email) { public static boolean hasUserIdFor(List<String> userIds, String email) {
String wanted = email.strip().toLowerCase(Locale.ROOT); String wanted = email.strip().toLowerCase(Locale.ROOT);
return userIds.stream() return userIds.stream()
.map(CertificateValidator::addressOf) .map(CertificateValidator::addressOf)
@@ -0,0 +1,61 @@
package pro.thinhha.mailsender.signing;
import java.util.Objects;
import java.util.Optional;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import jakarta.mail.Address;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.InternetAddress;
import jakarta.mail.internet.MimeMessage;
/**
* Picks the signer for a message by its From address. If there is none: with {@code required}, the message is
* refused ({@link MailSigningException}); otherwise it is sent unsigned and a warning is logged.
*/
public final class MailSigning {
private static final Logger log = LoggerFactory.getLogger(MailSigning.class);
private static final MailSigning DISABLED = new MailSigning(SignerResolver.none(), false, false);
private final SignerResolver signers;
private final boolean required;
private final boolean enabled;
public MailSigning(SignerResolver signers, boolean required) {
this(signers, required, true);
}
private MailSigning(SignerResolver signers, boolean required, boolean enabled) {
this.signers = Objects.requireNonNull(signers, "signers");
this.required = required;
this.enabled = enabled;
}
/** Never signs and never warns. */
public static MailSigning disabled() {
return DISABLED;
}
public Optional<MessageSigner> signerFor(MimeMessage message) throws MessagingException {
if (!enabled) {
return Optional.empty();
}
Address[] from = message.getFrom();
if (from == null || from.length == 0 || !(from[0] instanceof InternetAddress sender)) {
return missing("message has no From address");
}
Optional<MessageSigner> signer = signers.signerFor(sender.getAddress());
return signer.isPresent() ? signer : missing("no signing key for " + sender.getAddress());
}
private Optional<MessageSigner> missing(String reason) {
if (required) {
throw new MailSigningException("Signing is required but " + reason);
}
log.warn("Sending UNSIGNED: {}", reason);
return Optional.empty();
}
}
@@ -0,0 +1,9 @@
package pro.thinhha.mailsender.signing;
/** Signing is required but the message cannot be signed. Nothing was sent. */
public class MailSigningException extends RuntimeException {
public MailSigningException(String message) {
super(message);
}
}
@@ -0,0 +1,24 @@
package pro.thinhha.mailsender.signing;
import java.io.IOException;
import java.util.function.Supplier;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
/** Signs messages sent from one address. */
public sealed interface MessageSigner permits OpenPgpMessageSigner, SmimeMessageSigner {
SignatureType type();
/** The From address this signer's key/certificate belongs to. */
String address();
/**
* Returns a {@code multipart/signed} copy of {@code original} with the same envelope headers. The original is
* not modified.
*
* @param newMessage creates the new message, e.g. {@code JavaMailSender::createMimeMessage}
*/
MimeMessage sign(MimeMessage original, Supplier<MimeMessage> newMessage) throws MessagingException, IOException;
}
@@ -0,0 +1,101 @@
package pro.thinhha.mailsender.signing;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Objects;
import java.util.function.Supplier;
import org.bouncycastle.openpgp.api.OpenPGPKey;
import org.pgpainless.PGPainless;
import org.pgpainless.key.info.KeyRingInfo;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import pro.thinhha.mailsender.pgp.crypto.OpenPgpSigningKey;
import pro.thinhha.mailsender.pgp.crypto.PgpSigner;
import pro.thinhha.mailsender.pgp.mime.MimeEntities;
import pro.thinhha.mailsender.pgp.validation.CertificateValidator;
/**
* PGP/MIME signing (RFC 3156 section 5): {@code multipart/signed; protocol="application/pgp-signature"} with the
* content entity as the first part and a detached signature over its exact bytes as the second.
* <p>
* The key is checked when the signer is created: it must be able to sign, carry a User ID for the address, and be
* unlockable with the given passphrase.
*/
public final class OpenPgpMessageSigner implements MessageSigner {
private final String address;
private final OpenPgpSigningKey signingKey;
private final PgpSigner signer;
public OpenPgpMessageSigner(String address, OpenPGPKey key, char[] passphrase) {
this(address, key, passphrase, PGPainless.getInstance());
}
public OpenPgpMessageSigner(String address, OpenPGPKey key, char[] passphrase, PGPainless api) {
this.address = Objects.requireNonNull(address, "address").strip();
this.signingKey = OpenPgpSigningKey.of(Objects.requireNonNull(key, "key"), passphrase);
this.signer = new PgpSigner(api);
KeyRingInfo info = api.inspect(key);
if (!info.isUsableForSigning()) {
throw new IllegalArgumentException("OpenPGP key for " + address + " cannot sign (expired, revoked or "
+ "no signing subkey)");
}
if (!CertificateValidator.hasUserIdFor(info.getValidUserIds(), address)) {
throw new IllegalArgumentException("OpenPGP key has no valid user ID for " + address + ": "
+ info.getUserIds());
}
try {
signer.sign("probe\r\n".getBytes(StandardCharsets.US_ASCII), signingKey);
} catch (IOException e) {
throw new IllegalArgumentException("Cannot sign with the OpenPGP key for " + address
+ " (wrong passphrase?)", e);
}
}
@Override
public SignatureType type() {
return SignatureType.OPENPGP;
}
@Override
public String address() {
return address;
}
/** For signing inside the encryption of PGP/MIME-encrypted copies. */
public OpenPgpSigningKey signingKey() {
return signingKey;
}
@Override
public MimeMessage sign(MimeMessage original, Supplier<MimeMessage> newMessage)
throws MessagingException, IOException {
MimeMessage source = MimeEntities.snapshot(original);
byte[] entity = MimeEntities.contentEntity(source, false);
PgpSigner.DetachedSignature signature = signer.sign(entity, signingKey);
String boundary = MimeEntities.newBoundary("OpenPGP_signed");
ByteArrayOutputStream body = new ByteArrayOutputStream(entity.length + signature.armored().length + 512);
write(body, "This is an OpenPGP/MIME signed message (RFC 4880 and 3156)\r\n");
write(body, "--" + boundary + "\r\n");
body.write(entity);
write(body, "\r\n--" + boundary + "\r\n");
write(body, "Content-Type: application/pgp-signature; name=\"signature.asc\"\r\n");
write(body, "Content-Description: OpenPGP digital signature\r\n");
write(body, "Content-Disposition: attachment; filename=\"signature.asc\"\r\n\r\n");
body.write(MimeEntities.toCrlf(signature.armored()));
write(body, "\r\n--" + boundary + "--\r\n");
String contentType = "multipart/signed; micalg=" + signature.micalg()
+ "; protocol=\"application/pgp-signature\"; boundary=\"" + boundary + "\"";
return MimeEntities.withRawBody(source, contentType, body.toByteArray(), newMessage);
}
private static void write(ByteArrayOutputStream out, String text) {
out.writeBytes(text.getBytes(StandardCharsets.US_ASCII));
}
}
@@ -0,0 +1,11 @@
package pro.thinhha.mailsender.signing;
/** How outgoing mail is signed. */
public enum SignatureType {
/** Not signed. */
NONE,
/** OpenPGP: PGP/MIME {@code multipart/signed}, or signed inside the encryption for encrypted copies. */
OPENPGP,
/** S/MIME: {@code multipart/signed} with {@code application/pkcs7-signature} (RFC 8551). */
SMIME
}
@@ -0,0 +1,29 @@
package pro.thinhha.mailsender.signing;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
/** Finds the signer for a From address. */
@FunctionalInterface
public interface SignerResolver {
Optional<MessageSigner> signerFor(String fromAddress);
static SignerResolver none() {
return from -> Optional.empty();
}
/** Matches each signer by its {@link MessageSigner#address()}, ignoring case. */
static SignerResolver of(List<? extends MessageSigner> signers) {
Map<String, MessageSigner> byAddress = new HashMap<>();
for (MessageSigner signer : signers) {
if (byAddress.put(signer.address().toLowerCase(Locale.ROOT), signer) != null) {
throw new IllegalArgumentException("More than one signer for " + signer.address());
}
}
return from -> Optional.ofNullable(byAddress.get(from.strip().toLowerCase(Locale.ROOT)));
}
}
@@ -0,0 +1,209 @@
package pro.thinhha.mailsender.signing;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.security.GeneralSecurityException;
import java.security.KeyStore;
import java.security.PrivateKey;
import java.security.Signature;
import java.security.cert.Certificate;
import java.security.cert.CertificateParsingException;
import java.security.cert.X509Certificate;
import java.time.Clock;
import java.util.Arrays;
import java.util.Collection;
import java.util.Date;
import java.util.Enumeration;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.function.Supplier;
import javax.security.auth.x500.X500Principal;
import org.bouncycastle.asn1.ASN1ObjectIdentifier;
import org.bouncycastle.asn1.x500.RDN;
import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.asn1.x500.style.BCStyle;
import org.bouncycastle.asn1.x500.style.IETFUtils;
import org.bouncycastle.cert.jcajce.JcaCertStore;
import org.bouncycastle.cms.jcajce.JcaSimpleSignerInfoGeneratorBuilder;
import org.bouncycastle.mail.smime.SMIMESignedGenerator;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeBodyPart;
import jakarta.mail.internet.MimeMessage;
import jakarta.mail.internet.MimeMultipart;
import pro.thinhha.mailsender.pgp.mime.MimeEntities;
/**
* S/MIME signing (RFC 8551): {@code multipart/signed; protocol="application/pkcs7-signature"} with a detached CMS
* signature and the certificate chain attached.
* <p>
* The certificate is checked when the signer is created: it must be currently valid, name the address (as an
* rfc822Name subject alternative name or an emailAddress in the subject), allow digital signatures, and match the
* private key. Supported keys: RSA and EC.
*/
public final class SmimeMessageSigner implements MessageSigner {
private static final ASN1ObjectIdentifier EMAIL_ADDRESS = BCStyle.EmailAddress;
private static final int SAN_RFC822_NAME = 1;
private final String address;
private final PrivateKey privateKey;
private final List<X509Certificate> chain;
private final String algorithm;
/** @param chain the signer's certificate first, then intermediates */
public SmimeMessageSigner(String address, PrivateKey privateKey, List<X509Certificate> chain) {
this(address, privateKey, chain, Clock.systemUTC());
}
SmimeMessageSigner(String address, PrivateKey privateKey, List<X509Certificate> chain, Clock clock) {
this.address = Objects.requireNonNull(address, "address").strip();
this.privateKey = Objects.requireNonNull(privateKey, "privateKey");
this.chain = List.copyOf(chain);
if (this.chain.isEmpty()) {
throw new IllegalArgumentException("S/MIME certificate chain for " + address + " is empty");
}
this.algorithm = switch (privateKey.getAlgorithm()) {
case "RSA" -> "SHA256withRSA";
case "EC" -> "SHA256withECDSA";
default -> throw new IllegalArgumentException(
"Unsupported S/MIME key algorithm " + privateKey.getAlgorithm() + " for " + address);
};
validate(this.chain.getFirst(), clock);
}
/** Loads the key and chain from a PKCS#12 keystore; with {@code alias == null} the first key entry is used. */
public static SmimeMessageSigner fromPkcs12(String address, InputStream keystore, char[] password, String alias)
throws IOException, GeneralSecurityException {
KeyStore store = KeyStore.getInstance("PKCS12");
store.load(keystore, password);
String entry = alias == null || alias.isBlank() ? firstKeyAlias(store) : alias;
if (entry == null || !store.isKeyEntry(entry)) {
throw new IllegalArgumentException("No private key entry " + (alias == null ? "" : "'" + alias + "' ")
+ "in the S/MIME keystore for " + address);
}
PrivateKey key = (PrivateKey) store.getKey(entry, password);
Certificate[] certificates = store.getCertificateChain(entry);
if (certificates == null || certificates.length == 0) {
throw new IllegalArgumentException("No certificate chain for entry '" + entry + "' (" + address + ")");
}
return new SmimeMessageSigner(address, key,
Arrays.stream(certificates).map(X509Certificate.class::cast).toList());
}
@Override
public SignatureType type() {
return SignatureType.SMIME;
}
@Override
public String address() {
return address;
}
public X509Certificate certificate() {
return chain.getFirst();
}
@Override
public MimeMessage sign(MimeMessage original, Supplier<MimeMessage> newMessage)
throws MessagingException, IOException {
MimeMessage source = MimeEntities.snapshot(original);
// A parsed part is written back byte for byte, so the signed bytes are the bytes that get sent.
MimeBodyPart content = new MimeBodyPart(new ByteArrayInputStream(MimeEntities.contentEntity(source, false)));
MimeMultipart signed;
try {
SMIMESignedGenerator generator = new SMIMESignedGenerator();
generator.addSignerInfoGenerator(new JcaSimpleSignerInfoGeneratorBuilder()
.build(algorithm, privateKey, chain.getFirst()));
generator.addCertificates(new JcaCertStore(chain));
signed = generator.generate(content);
} catch (Exception e) {
throw new MessagingException("S/MIME signing failed: " + e.getMessage(), e);
}
// Write once to compute the signature, then keep exactly those bytes.
MimeMessage holder = new MimeMessage((jakarta.mail.Session) null);
holder.setContent(signed, signed.getContentType());
holder.saveChanges();
MimeMessage frozen = MimeEntities.parse(MimeEntities.serialize(holder));
byte[] body;
try (InputStream raw = frozen.getRawInputStream()) {
body = raw.readAllBytes();
}
return MimeEntities.withRawBody(source, frozen.getContentType(), body, newMessage);
}
private void validate(X509Certificate certificate, Clock clock) {
try {
certificate.checkValidity(Date.from(clock.instant()));
} catch (GeneralSecurityException e) {
throw new IllegalArgumentException("S/MIME certificate for " + address + " is not valid now: "
+ e.getMessage(), e);
}
if (!certificateNames(certificate, address)) {
throw new IllegalArgumentException("S/MIME certificate does not name " + address + ": "
+ certificate.getSubjectX500Principal().getName(X500Principal.RFC2253));
}
boolean[] keyUsage = certificate.getKeyUsage();
if (keyUsage != null && !(keyUsage[0] || keyUsage[1])) {
throw new IllegalArgumentException("S/MIME certificate for " + address
+ " does not allow digital signatures");
}
try {
byte[] probe = "probe".getBytes(java.nio.charset.StandardCharsets.US_ASCII);
Signature signer = Signature.getInstance(algorithm);
signer.initSign(privateKey);
signer.update(probe);
byte[] signature = signer.sign();
Signature verifier = Signature.getInstance(algorithm);
verifier.initVerify(certificate.getPublicKey());
verifier.update(probe);
if (!verifier.verify(signature)) {
throw new IllegalArgumentException("S/MIME private key does not match the certificate for " + address);
}
} catch (GeneralSecurityException e) {
throw new IllegalArgumentException("Cannot sign with the S/MIME key for " + address, e);
}
}
static boolean certificateNames(X509Certificate certificate, String email) {
String wanted = email.strip().toLowerCase(Locale.ROOT);
try {
Collection<List<?>> names = certificate.getSubjectAlternativeNames();
if (names != null) {
for (List<?> name : names) {
if (Integer.valueOf(SAN_RFC822_NAME).equals(name.get(0))
&& wanted.equals(String.valueOf(name.get(1)).toLowerCase(Locale.ROOT))) {
return true;
}
}
}
} catch (CertificateParsingException e) {
return false;
}
X500Name subject = X500Name.getInstance(certificate.getSubjectX500Principal().getEncoded());
for (RDN rdn : subject.getRDNs(EMAIL_ADDRESS)) {
if (wanted.equals(IETFUtils.valueToString(rdn.getFirst().getValue()).toLowerCase(Locale.ROOT))) {
return true;
}
}
return false;
}
private static String firstKeyAlias(KeyStore store) throws GeneralSecurityException {
Enumeration<String> aliases = store.aliases();
while (aliases.hasMoreElements()) {
String alias = aliases.nextElement();
if (store.isKeyEntry(alias)) {
return alias;
}
}
return null;
}
}
@@ -49,7 +49,7 @@ class MessageAssemblerTest {
Map.of("Sender@Example.com", SENDER.toCertificate()), new CertificateValidator()); Map.of("Sender@Example.com", SENDER.toCertificate()), new CertificateValidator());
List<MimeMessage> messages = new MessageAssembler(new PgpMimeEncryptor(new PgpEncryptor()), senderKeys) List<MimeMessage> messages = new MessageAssembler(new PgpMimeEncryptor(new PgpEncryptor()), senderKeys)
.assemble(original, plan, () -> new MimeMessage(SESSION)); .assemble(original, plan, () -> new MimeMessage(SESSION)).messages();
assertThat(messages).hasSize(3); assertThat(messages).hasSize(3);
assertThat(messages).extracting(MimeMessage::getMessageID).doesNotContainNull().containsOnly( assertThat(messages).extracting(MimeMessage::getMessageID).doesNotContainNull().containsOnly(
@@ -0,0 +1,133 @@
package pro.thinhha.mailsender.signing;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
import static pro.thinhha.mailsender.signing.SigningTestSupport.SESSION;
import static pro.thinhha.mailsender.signing.SigningTestSupport.transport;
import static pro.thinhha.mailsender.signing.SigningTestSupport.verifyOpenPgp;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.util.List;
import org.bouncycastle.openpgp.api.OpenPGPKey;
import org.junit.jupiter.api.Test;
import org.pgpainless.PGPainless;
import org.pgpainless.decryption_verification.ConsumerOptions;
import org.pgpainless.decryption_verification.DecryptionStream;
import jakarta.activation.DataHandler;
import jakarta.mail.Message;
import jakarta.mail.Multipart;
import jakarta.mail.internet.ContentType;
import jakarta.mail.internet.InternetAddress;
import jakarta.mail.internet.MimeBodyPart;
import jakarta.mail.internet.MimeMessage;
import jakarta.mail.internet.MimeMultipart;
import jakarta.mail.util.ByteArrayDataSource;
import pro.thinhha.mailsender.pgp.crypto.PgpEncryptor;
class OpenPgpMessageSignerTest {
private static final PGPainless API = PGPainless.getInstance();
private static final OpenPGPKey SENDER = API.generateKey().modernKeyRing("App <app@example.com>");
private static final OpenPGPKey PROTECTED = API.generateKey().modernKeyRing("App <app@example.com>", "s3cret");
private static final OpenPGPKey ALICE = API.generateKey().modernKeyRing("<alice@example.org>");
private final OpenPgpMessageSigner signer = new OpenPgpMessageSigner("app@example.com", SENDER, null);
@Test
void signedTextVerifiesAfterTransport() throws Exception {
MimeMessage message = message();
message.setText("Grüße aus Hà Nội\nline two \nFrom the start of a line", "UTF-8");
MimeMessage wire = transport(signer.sign(message, () -> new MimeMessage(SESSION)));
ContentType type = new ContentType(wire.getContentType());
assertThat(type.getBaseType()).isEqualTo("multipart/signed");
assertThat(type.getParameter("protocol")).isEqualTo("application/pgp-signature");
assertThat(type.getParameter("micalg")).startsWith("pgp-sha");
assertThat(wire.getSubject()).isEqualTo("Signed");
assertThat(wire.getRecipients(Message.RecipientType.TO)).hasSize(1);
Multipart parts = (Multipart) wire.getContent();
assertThat(parts.getBodyPart(0).getContent().toString()).startsWith("Grüße aus Hà Nội");
assertThat(verifyOpenPgp(wire, SENDER.toCertificate())).isTrue();
assertThat(verifyOpenPgp(wire, ALICE.toCertificate())).as("other key").isFalse();
}
@Test
void signedMultipartWithAttachmentVerifies() throws Exception {
MimeMessage message = message();
MimeMultipart mixed = new MimeMultipart("mixed");
MimeBodyPart text = new MimeBodyPart();
text.setText("see attachment", "UTF-8");
MimeBodyPart attachment = new MimeBodyPart();
attachment.setDataHandler(new DataHandler(new ByteArrayDataSource(new byte[] {0, 1, 2, -1}, "application/pdf")));
attachment.setFileName("a.pdf");
mixed.addBodyPart(text);
mixed.addBodyPart(attachment);
message.setContent(mixed);
MimeMessage wire = transport(transport(signer.sign(message, () -> new MimeMessage(SESSION))));
assertThat(verifyOpenPgp(wire, SENDER.toCertificate())).isTrue();
MimeBodyPart signedEntity = (MimeBodyPart) ((Multipart) wire.getContent()).getBodyPart(0);
assertThat(signedEntity.isMimeType("multipart/mixed")).isTrue();
}
@Test
void tamperedContentDoesNotVerify() throws Exception {
MimeMessage message = message();
message.setText("pay 10 EUR");
byte[] wire = SigningTestSupport.transport(signer.sign(message, () -> new MimeMessage(SESSION)))
.getRawInputStream().readAllBytes();
MimeMessage signed = transport(signer.sign(message, () -> new MimeMessage(SESSION)));
String tampered = new String(SigningTestSupport.serialize(signed), java.nio.charset.StandardCharsets.UTF_8)
.replace("pay 10 EUR", "pay 99 EUR");
MimeMessage forged = new MimeMessage(SESSION, new ByteArrayInputStream(
tampered.getBytes(java.nio.charset.StandardCharsets.UTF_8)));
assertThat(wire).isNotEmpty();
assertThat(verifyOpenPgp(forged, SENDER.toCertificate())).isFalse();
}
@Test
void signsInsideEncryption() throws Exception {
byte[] ciphertext = new PgpEncryptor().encrypt("inner\r\n".getBytes(), List.of(ALICE.toCertificate()),
List.of(), signer.signingKey());
ByteArrayOutputStream plain = new ByteArrayOutputStream();
DecryptionStream in = API.processMessage().onInputStream(new ByteArrayInputStream(ciphertext))
.withOptions(ConsumerOptions.get(API).addDecryptionKey(ALICE).addVerificationCert(SENDER.toCertificate()));
in.transferTo(plain);
in.close();
assertThat(plain.toString()).isEqualTo("inner\r\n");
assertThat(in.getMetadata().isVerifiedSignedBy(SENDER.toCertificate())).isTrue();
}
@Test
void protectedKeyNeedsTheRightPassphrase() {
new OpenPgpMessageSigner("app@example.com", PROTECTED, "s3cret".toCharArray());
assertThatIllegalArgumentException()
.isThrownBy(() -> new OpenPgpMessageSigner("app@example.com", PROTECTED, "wrong".toCharArray()))
.withMessageContaining("passphrase");
}
@Test
void keyMustBelongToTheAddress() {
assertThatIllegalArgumentException()
.isThrownBy(() -> new OpenPgpMessageSigner("other@example.com", SENDER, null))
.withMessageContaining("no valid user ID");
}
private static MimeMessage message() throws Exception {
MimeMessage message = new MimeMessage(SESSION);
message.setFrom(new InternetAddress("app@example.com"));
message.setRecipient(Message.RecipientType.TO, new InternetAddress("alice@example.org"));
message.setSubject("Signed");
return message;
}
}
@@ -0,0 +1,156 @@
package pro.thinhha.mailsender.signing;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.cert.X509Certificate;
import java.time.Duration;
import java.time.Instant;
import java.util.Arrays;
import java.util.Date;
import java.util.List;
import java.util.Properties;
import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.asn1.x509.ExtendedKeyUsage;
import org.bouncycastle.asn1.x509.Extension;
import org.bouncycastle.asn1.x509.GeneralName;
import org.bouncycastle.asn1.x509.GeneralNames;
import org.bouncycastle.asn1.x509.KeyPurposeId;
import org.bouncycastle.asn1.x509.KeyUsage;
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
import org.bouncycastle.cms.SignerInformation;
import org.bouncycastle.cms.jcajce.JcaSimpleSignerInfoVerifierBuilder;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.mail.smime.SMIMESigned;
import org.bouncycastle.openpgp.api.OpenPGPCertificate;
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
import org.pgpainless.PGPainless;
import org.pgpainless.decryption_verification.ConsumerOptions;
import org.pgpainless.decryption_verification.DecryptionStream;
import jakarta.mail.Multipart;
import jakarta.mail.Session;
import jakarta.mail.internet.ContentType;
import jakarta.mail.internet.MimeMessage;
import jakarta.mail.internet.MimeMultipart;
/** Test keys/certificates and client-side verification of signed messages. */
public final class SigningTestSupport {
public static final Session SESSION = Session.getInstance(new Properties());
private static final BouncyCastleProvider BC = new BouncyCastleProvider();
private SigningTestSupport() {
}
public record SmimeIdentity(KeyPair keyPair, X509Certificate certificate) {
}
/** Self-signed S/MIME certificate for {@code email} (rfc822Name SAN, digitalSignature, emailProtection). */
public static SmimeIdentity smimeIdentity(String email, String keyAlgorithm, Instant notBefore, Instant notAfter)
throws Exception {
KeyPairGenerator generator = KeyPairGenerator.getInstance(keyAlgorithm);
generator.initialize(keyAlgorithm.equals("RSA") ? 2048 : 256);
KeyPair keyPair = generator.generateKeyPair();
X500Name name = new X500Name("CN=" + email);
JcaX509v3CertificateBuilder builder = new JcaX509v3CertificateBuilder(name,
BigInteger.valueOf(System.nanoTime()), Date.from(notBefore), Date.from(notAfter), name,
keyPair.getPublic());
builder.addExtension(Extension.subjectAlternativeName, false,
new GeneralNames(new GeneralName(GeneralName.rfc822Name, email)));
builder.addExtension(Extension.keyUsage, true,
new KeyUsage(KeyUsage.digitalSignature | KeyUsage.keyEncipherment));
builder.addExtension(Extension.extendedKeyUsage, false,
new ExtendedKeyUsage(KeyPurposeId.id_kp_emailProtection));
String signatureAlgorithm = keyAlgorithm.equals("RSA") ? "SHA256withRSA" : "SHA256withECDSA";
X509Certificate certificate = new JcaX509CertificateConverter().getCertificate(
builder.build(new JcaContentSignerBuilder(signatureAlgorithm).build(keyPair.getPrivate())));
return new SmimeIdentity(keyPair, certificate);
}
public static SmimeIdentity smimeIdentity(String email) throws Exception {
return smimeIdentity(email, "RSA", Instant.now().minus(Duration.ofDays(1)),
Instant.now().plus(Duration.ofDays(365)));
}
/** Serializes like SMTP would and parses like a receiving client. */
public static MimeMessage transport(MimeMessage message) throws Exception {
return new MimeMessage(SESSION, new ByteArrayInputStream(serialize(message)));
}
public static byte[] serialize(MimeMessage message) throws Exception {
ByteArrayOutputStream out = new ByteArrayOutputStream();
message.writeTo(out);
return out.toByteArray();
}
/**
* Verifies a PGP/MIME {@code multipart/signed} message the way RFC 3156 clients do: the first part's raw bytes
* between the boundaries against the detached signature in the second part.
*/
public static boolean verifyOpenPgp(MimeMessage message, OpenPGPCertificate signer) throws Exception {
ContentType type = new ContentType(message.getContentType());
if (!type.match("multipart/signed") || !"application/pgp-signature".equals(type.getParameter("protocol"))) {
return false;
}
byte[] body = message.getRawInputStream().readAllBytes();
byte[] signedPart = firstPart(body, type.getParameter("boundary"));
byte[] signature = ((Multipart) message.getContent()).getBodyPart(1).getInputStream().readAllBytes();
return verifyDetached(signedPart, signature, signer);
}
public static boolean verifyDetached(byte[] data, byte[] signature, OpenPGPCertificate signer) throws Exception {
PGPainless api = PGPainless.getInstance();
try (DecryptionStream in = api.processMessage().onInputStream(new ByteArrayInputStream(data))
.withOptions(ConsumerOptions.get(api)
.addVerificationOfDetachedSignatures(new ByteArrayInputStream(signature))
.addVerificationCert(signer))) {
in.transferTo(OutputStreamHolder.NULL);
in.close();
return in.getMetadata().isVerifiedSignedBy(signer);
}
}
/** Verifies an S/MIME {@code multipart/signed} message with Bouncy Castle against {@code signer}. */
public static boolean verifySmime(MimeMessage message, X509Certificate signer) throws Exception {
if (!message.isMimeType("multipart/signed")) {
return false;
}
SMIMESigned signed = new SMIMESigned((MimeMultipart) message.getContent());
List<SignerInformation> signers = List.copyOf(signed.getSignerInfos().getSigners());
if (signers.size() != 1) {
return false;
}
return signers.getFirst().verify(new JcaSimpleSignerInfoVerifierBuilder().setProvider(BC).build(signer));
}
static byte[] firstPart(byte[] body, String boundary) {
byte[] open = ("--" + boundary + "\r\n").getBytes(StandardCharsets.US_ASCII);
byte[] close = ("\r\n--" + boundary).getBytes(StandardCharsets.US_ASCII);
int start = indexOf(body, open, 0) + open.length;
int end = indexOf(body, close, start);
return Arrays.copyOfRange(body, start, end);
}
private static int indexOf(byte[] data, byte[] pattern, int from) {
outer:
for (int i = from; i <= data.length - pattern.length; i++) {
for (int j = 0; j < pattern.length; j++) {
if (data[i + j] != pattern[j]) {
continue outer;
}
}
return i;
}
throw new IllegalArgumentException("pattern not found");
}
private static final class OutputStreamHolder {
static final java.io.OutputStream NULL = java.io.OutputStream.nullOutputStream();
}
}
@@ -0,0 +1,108 @@
package pro.thinhha.mailsender.signing;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
import static pro.thinhha.mailsender.signing.SigningTestSupport.SESSION;
import static pro.thinhha.mailsender.signing.SigningTestSupport.smimeIdentity;
import static pro.thinhha.mailsender.signing.SigningTestSupport.transport;
import static pro.thinhha.mailsender.signing.SigningTestSupport.verifySmime;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.security.KeyStore;
import java.security.cert.Certificate;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.List;
import org.junit.jupiter.api.Test;
import jakarta.mail.Message;
import jakarta.mail.Multipart;
import jakarta.mail.internet.ContentType;
import jakarta.mail.internet.InternetAddress;
import jakarta.mail.internet.MimeMessage;
class SmimeMessageSignerTest {
@Test
void rsaSignatureVerifiesAfterTransport() throws Exception {
assertSignsAndVerifies("RSA");
}
@Test
void ecSignatureVerifiesAfterTransport() throws Exception {
assertSignsAndVerifies("EC");
}
@Test
void loadsFromPkcs12() throws Exception {
SigningTestSupport.SmimeIdentity identity = smimeIdentity("app@example.com");
KeyStore store = KeyStore.getInstance("PKCS12");
store.load(null, null);
store.setKeyEntry("mail", identity.keyPair().getPrivate(), "pw".toCharArray(),
new Certificate[] {identity.certificate()});
ByteArrayOutputStream p12 = new ByteArrayOutputStream();
store.store(p12, "pw".toCharArray());
SmimeMessageSigner signer = SmimeMessageSigner.fromPkcs12("app@example.com",
new ByteArrayInputStream(p12.toByteArray()), "pw".toCharArray(), null);
assertThat(signer.certificate()).isEqualTo(identity.certificate());
}
@Test
void certificateMustNameTheAddress() throws Exception {
SigningTestSupport.SmimeIdentity identity = smimeIdentity("someone@example.com");
assertThatIllegalArgumentException().isThrownBy(() -> new SmimeMessageSigner("app@example.com",
identity.keyPair().getPrivate(), List.of(identity.certificate())))
.withMessageContaining("does not name app@example.com");
}
@Test
void certificateMustBeValidNow() throws Exception {
SigningTestSupport.SmimeIdentity identity = smimeIdentity("app@example.com");
Clock later = Clock.fixed(Instant.now().plus(Duration.ofDays(400)), ZoneOffset.UTC);
assertThatIllegalArgumentException().isThrownBy(() -> new SmimeMessageSigner("app@example.com",
identity.keyPair().getPrivate(), List.of(identity.certificate()), later))
.withMessageContaining("not valid now");
}
@Test
void privateKeyMustMatchCertificate() throws Exception {
SigningTestSupport.SmimeIdentity identity = smimeIdentity("app@example.com");
SigningTestSupport.SmimeIdentity other = smimeIdentity("app@example.com");
assertThatIllegalArgumentException().isThrownBy(() -> new SmimeMessageSigner("app@example.com",
other.keyPair().getPrivate(), List.of(identity.certificate())))
.withMessageContaining("does not match");
}
private static void assertSignsAndVerifies(String keyAlgorithm) throws Exception {
SigningTestSupport.SmimeIdentity identity = smimeIdentity("app@example.com", keyAlgorithm,
Instant.now().minus(Duration.ofDays(1)), Instant.now().plus(Duration.ofDays(30)));
SmimeMessageSigner signer = new SmimeMessageSigner("App@Example.com", identity.keyPair().getPrivate(),
List.of(identity.certificate()));
MimeMessage message = new MimeMessage(SESSION);
message.setFrom(new InternetAddress("app@example.com"));
message.setRecipient(Message.RecipientType.TO, new InternetAddress("bob@example.org"));
message.setSubject("S/MIME");
message.setText("Grüße\nline two", "UTF-8");
MimeMessage wire = transport(transport(signer.sign(message, () -> new MimeMessage(SESSION))));
ContentType type = new ContentType(wire.getContentType());
assertThat(type.getBaseType()).isEqualTo("multipart/signed");
assertThat(type.getParameter("protocol")).isEqualTo("application/pkcs7-signature");
assertThat(wire.getSubject()).isEqualTo("S/MIME");
assertThat(((Multipart) wire.getContent()).getBodyPart(0).getContent().toString()).startsWith("Grüße");
assertThat(verifySmime(wire, identity.certificate())).isTrue();
assertThat(verifySmime(wire, smimeIdentity("app@example.com", keyAlgorithm,
Instant.now().minus(Duration.ofDays(1)), Instant.now().plus(Duration.ofDays(30))).certificate()))
.as("other certificate").isFalse();
}
}
@@ -52,6 +52,13 @@
<artifactId>spring-boot-starter-test</artifactId> <artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope> <scope>test</scope>
</dependency> </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> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId> <artifactId>spring-boot-starter-mail</artifactId>
@@ -9,7 +9,11 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Properties; import java.util.Properties;
import java.security.GeneralSecurityException;
import org.bouncycastle.openpgp.api.OpenPGPCertificate; import org.bouncycastle.openpgp.api.OpenPGPCertificate;
import org.bouncycastle.openpgp.api.OpenPGPKey;
import org.pgpainless.PGPainless;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.ObjectProvider; 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.RelaySecureMailSender;
import pro.thinhha.mailsender.pgp.sender.SecureMailSender; import pro.thinhha.mailsender.pgp.sender.SecureMailSender;
import pro.thinhha.mailsender.pgp.validation.CertificateValidator; 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), * Wires {@link SecureMailSender} and its parts: key discovery (pinned keys first, then WKD behind a cache),
@@ -154,15 +163,40 @@ public class PgpMailAutoConfiguration {
@Bean @Bean
@ConditionalOnMissingBean @ConditionalOnMissingBean
MessageAssembler pgpMessageAssembler(PgpMimeEncryptor mimeEncryptor, SenderKeyProvider senderKeys) { MailSigning pgpMailSigning(PgpMailProperties properties) {
return new MessageAssembler(mimeEncryptor, senderKeys); 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 @Bean
@ConditionalOnMissingBean @ConditionalOnMissingBean
SecureMailSender pgpSecureMailSender(PgpMailProperties properties, ObjectProvider<JavaMailSender> mailSender, SecureMailSender pgpSecureMailSender(PgpMailProperties properties, ObjectProvider<JavaMailSender> mailSender,
ObjectProvider<DeliveryPlanner> planner, ObjectProvider<MessageAssembler> assembler, ObjectProvider<DeliveryPlanner> planner, ObjectProvider<MessageAssembler> assembler,
PolicyResolver policies, ObjectProvider<SecureMailSenderCustomizer> customizers) { PolicyResolver policies, MailSigning signing, ObjectProvider<SecureMailSenderCustomizer> customizers) {
SecureMailSender sender = switch (properties.mode()) { SecureMailSender sender = switch (properties.mode()) {
case BUILT_IN -> { case BUILT_IN -> {
JavaMailSender delegate = mailSender.getIfAvailable(); JavaMailSender delegate = mailSender.getIfAvailable();
@@ -176,7 +210,7 @@ public class PgpMailAutoConfiguration {
PgpMailProperties.Relay relay = properties.relay(); PgpMailProperties.Relay relay = properties.relay();
log.info("mail.pgp.mode=RELAY: key discovery, encryption and policy enforcement are delegated to " log.info("mail.pgp.mode=RELAY: key discovery, encryption and policy enforcement are delegated to "
+ "the gateway at {}:{}", relay.host(), relay.port()); + "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()) { for (SecureMailSenderCustomizer customizer : customizers.orderedStream().toList()) {
@@ -204,6 +238,34 @@ public class PgpMailAutoConfiguration {
return sender; 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) { private static Map<String, List<OpenPGPCertificate>> readAll(String property, Map<String, Resource> resources) {
Map<String, List<OpenPGPCertificate>> certificates = new LinkedHashMap<>(); Map<String, List<OpenPGPCertificate>> certificates = new LinkedHashMap<>();
resources.forEach((email, resource) -> certificates.put(email, read(property, email, resource))); 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.EncryptionPolicy;
import pro.thinhha.mailsender.pgp.delivery.MixedMode; import pro.thinhha.mailsender.pgp.delivery.MixedMode;
import pro.thinhha.mailsender.pgp.discovery.WkdFallback; import pro.thinhha.mailsender.pgp.discovery.WkdFallback;
import pro.thinhha.mailsender.signing.SignatureType;
/** /**
* {@code mail.pgp.*} configuration. * {@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 recipients policy rules per address or {@code *@domain}
* @param staticKeys pinned recipient certificates per address, checked before WKD * @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 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") @ConfigurationProperties("mail.pgp")
public record PgpMailProperties( public record PgpMailProperties(
@@ -40,6 +42,7 @@ public record PgpMailProperties(
@DefaultValue Cache cache, @DefaultValue Cache cache,
Map<String, Resource> staticKeys, Map<String, Resource> staticKeys,
Map<String, Resource> senderKeys, Map<String, Resource> senderKeys,
@DefaultValue Signing signing,
@DefaultValue Relay relay) { @DefaultValue Relay relay) {
public PgpMailProperties { public PgpMailProperties {
@@ -75,6 +78,42 @@ public record PgpMailProperties(
@DefaultValue("10000") long maxSize) { @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 * SMTP connection to the encrypting gateway ({@code mode=RELAY}). The hop carries plaintext, so STARTTLS is
* required by default. * required by default.
@@ -11,10 +11,11 @@ import jakarta.mail.internet.MimeMessage;
import pro.thinhha.mailsender.pgp.delivery.EncryptionPolicyException; import pro.thinhha.mailsender.pgp.delivery.EncryptionPolicyException;
import pro.thinhha.mailsender.pgp.sender.SecureMailSender; import pro.thinhha.mailsender.pgp.sender.SecureMailSender;
import pro.thinhha.mailsender.pgp.sender.SendReport; 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 * Counts per recipient {@code mail.pgp.recipients{outcome, policy, reason, signature}} and refused messages
* {@code mail.pgp.refused}. A plaintext recipient with policy {@code opportunistic} is a downgrade. * {@code mail.pgp.refused{cause}}. A plaintext recipient with policy {@code opportunistic} is a downgrade.
*/ */
public final class MeteredSecureMailSender implements SecureMailSender { public final class MeteredSecureMailSender implements SecureMailSender {
@@ -39,7 +40,10 @@ public final class MeteredSecureMailSender implements SecureMailSender {
try { try {
return record(delegate.send(message)); return record(delegate.send(message));
} catch (EncryptionPolicyException e) { } catch (EncryptionPolicyException e) {
refused(); refused("encryption_policy");
throw e;
} catch (MailSigningException e) {
refused("signing_required");
throw e; throw e;
} }
} }
@@ -49,7 +53,10 @@ public final class MeteredSecureMailSender implements SecureMailSender {
try { try {
return record(delegate.send(message)); return record(delegate.send(message));
} catch (EncryptionPolicyException e) { } catch (EncryptionPolicyException e) {
refused(); refused("encryption_policy");
throw e;
} catch (MailSigningException e) {
refused("signing_required");
throw e; throw e;
} }
} }
@@ -61,15 +68,16 @@ public final class MeteredSecureMailSender implements SecureMailSender {
.tag("outcome", lower(recipient.outcome())) .tag("outcome", lower(recipient.outcome()))
.tag("policy", lower(recipient.policy())) .tag("policy", lower(recipient.policy()))
.tag("reason", lower(recipient.reason())) .tag("reason", lower(recipient.reason()))
.tag("signature", lower(report.signature()))
.register(registry) .register(registry)
.increment(); .increment();
} }
return report; return report;
} }
private void refused() { private void refused(String cause) {
Counter.builder(REFUSED).description("Messages refused by the encryption policy").register(registry) Counter.builder(REFUSED).description("Messages refused by the encryption or signing policy")
.increment(); .tag("cause", cause).register(registry).increment();
} }
private static String lower(Enum<?> value) { 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.CC),
recipients(message, Message.RecipientType.BCC)); recipients(message, Message.RecipientType.BCC));
List<MimeMessage> copies; MessageAssembler.Assembly assembly;
try { try {
copies = assembler.assemble(message, plan, delegate::createMimeMessage); assembly = assembler.assemble(message, plan, delegate::createMimeMessage);
} catch (MessagingException | IOException e) { } 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 -> plan.decisions().stream().filter(RecipientDecision::isDowngrade).forEach(d ->
log.warn("Sending to {} in PLAINTEXT (policy {}): {}", d.address(), d.policy(), describe(d.key()))); log.warn("Sending to {} in PLAINTEXT (policy {}): {}", d.address(), d.policy(), describe(d.key())));
delegate.send(copies.toArray(MimeMessage[]::new)); 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(), .map(d -> new SendReport.RecipientOutcome(d.address(), d.type(), d.policy(),
d.encrypted() ? SendReport.Outcome.ENCRYPTED : SendReport.Outcome.PLAINTEXT, d.encrypted() ? SendReport.Outcome.ENCRYPTED : SendReport.Outcome.PLAINTEXT,
d.encrypted() ? SendReport.PlaintextReason.NONE : reason(d.key()), 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.EncryptionPolicy;
import pro.thinhha.mailsender.pgp.delivery.PolicyResolver; import pro.thinhha.mailsender.pgp.delivery.PolicyResolver;
import pro.thinhha.mailsender.pgp.delivery.RecipientType; 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 * 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 * {@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. * 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 { public final class RelaySecureMailSender extends AbstractSecureMailSender {
private final JavaMailSender relay; private final JavaMailSender relay;
private final PolicyResolver policies; private final PolicyResolver policies;
private final String policyHeader; private final String policyHeader;
private final MailSigning signing;
/** @param policyHeader header carrying the policy hint, or {@code null} for none */ /** @param policyHeader header carrying the policy hint, or {@code null} for none */
public RelaySecureMailSender(JavaMailSender relay, PolicyResolver policies, String policyHeader) { 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.relay = Objects.requireNonNull(relay, "relay");
this.policies = Objects.requireNonNull(policies, "policies"); this.policies = Objects.requireNonNull(policies, "policies");
this.policyHeader = policyHeader == null || policyHeader.isBlank() ? null : policyHeader; this.policyHeader = policyHeader == null || policyHeader.isBlank() ? null : policyHeader;
this.signing = Objects.requireNonNull(signing, "signing");
} }
@Override @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.CC), RecipientType.CC);
addOutcomes(outcomes, recipients(message, Message.RecipientType.BCC), RecipientType.BCC); 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()) { if (policyHeader != null && !outcomes.isEmpty()) {
// Enum order is strictest first: REQUIRE, OPPORTUNISTIC, PLAINTEXT. // Enum order is strictest first: REQUIRE, OPPORTUNISTIC, PLAINTEXT.
EncryptionPolicy strictest = outcomes.stream().map(SendReport.RecipientOutcome::policy) EncryptionPolicy strictest = outcomes.stream().map(SendReport.RecipientOutcome::policy)
@@ -58,7 +82,7 @@ public final class RelaySecureMailSender extends AbstractSecureMailSender {
} }
} }
relay.send(message); relay.send(message);
return new SendReport(outcomes); return new SendReport(signature, outcomes);
} }
private void addOutcomes(List<SendReport.RecipientOutcome> outcomes, List<InternetAddress> addresses, 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.EncryptionPolicy;
import pro.thinhha.mailsender.pgp.delivery.RecipientType; 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 { public SendReport {
signature = signature == null ? SignatureType.NONE : signature;
recipients = List.copyOf(recipients); recipients = List.copyOf(recipients);
} }
/** An unsigned message. */
public SendReport(List<RecipientOutcome> recipients) {
this(SignatureType.NONE, recipients);
}
public boolean allEncrypted() { public boolean allEncrypted() {
return recipients.stream().allMatch(r -> r.outcome() == Outcome.ENCRYPTED); return recipients.stream().allMatch(r -> r.outcome() == Outcome.ENCRYPTED);
} }
@@ -42,7 +42,8 @@ class MeteredSecureMailSenderTest {
assertThatThrownBy(() -> new MeteredSecureMailSender(failing, registry).send(new SimpleMailMessage())) assertThatThrownBy(() -> new MeteredSecureMailSender(failing, registry).send(new SimpleMailMessage()))
.isInstanceOf(EncryptionPolicyException.class); .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 @Test
@@ -55,7 +56,7 @@ class MeteredSecureMailSenderTest {
private double count(String outcome, String reason) { private double count(String outcome, String reason) {
return registry.get(MeteredSecureMailSender.RECIPIENTS) 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(); .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();
}
}
+17
View File
@@ -26,6 +26,7 @@
<spring-boot.version>4.1.1</spring-boot.version> <spring-boot.version>4.1.1</spring-boot.version>
<pgpainless.version>2.0.4</pgpainless.version> <pgpainless.version>2.0.4</pgpainless.version>
<wkd-java.version>0.1.3</wkd-java.version> <wkd-java.version>0.1.3</wkd-java.version>
<bouncycastle.version>1.84</bouncycastle.version>
<caffeine.version>3.3.0</caffeine.version> <caffeine.version>3.3.0</caffeine.version>
<greenmail.version>2.1.14</greenmail.version> <greenmail.version>2.1.14</greenmail.version>
</properties> </properties>
@@ -59,6 +60,17 @@
<artifactId>pgpainless-core</artifactId> <artifactId>pgpainless-core</artifactId>
<version>${pgpainless.version}</version> <version>${pgpainless.version}</version>
</dependency> </dependency>
<dependency>
<!-- same Bouncy Castle release as PGPainless uses -->
<groupId>org.bouncycastle</groupId>
<artifactId>bcpkix-jdk18on</artifactId>
<version>${bouncycastle.version}</version>
</dependency>
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcjmail-jdk18on</artifactId>
<version>${bouncycastle.version}</version>
</dependency>
<dependency> <dependency>
<groupId>org.pgpainless</groupId> <groupId>org.pgpainless</groupId>
<artifactId>wkd-java</artifactId> <artifactId>wkd-java</artifactId>
@@ -93,6 +105,11 @@
<artifactId>maven-surefire-plugin</artifactId> <artifactId>maven-surefire-plugin</artifactId>
<version>3.5.3</version> <version>3.5.3</version>
</plugin> </plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.4.2</version>
</plugin>
<plugin> <plugin>
<groupId>org.apache.maven.plugins</groupId> <groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-deploy-plugin</artifactId> <artifactId>maven-deploy-plugin</artifactId>