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

+26 -1
View File
@@ -11,7 +11,7 @@
</parent>
<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>
<dependency>
@@ -22,6 +22,15 @@
<groupId>org.pgpainless</groupId>
<artifactId>wkd-java</artifactId>
</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>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
@@ -52,4 +61,20 @@
<scope>test</scope>
</dependency>
</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>
@@ -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.api.OpenPGPCertificate;
import org.pgpainless.PGPainless;
import org.pgpainless.algorithm.DocumentSignatureType;
import org.pgpainless.encryption_signing.EncryptionOptions;
import org.pgpainless.encryption_signing.EncryptionStream;
import org.pgpainless.encryption_signing.ProducerOptions;
import org.pgpainless.encryption_signing.SigningOptions;
/** Encrypts data to OpenPGP certificates and returns the ASCII-armored message. */
public final class PgpEncryptor {
@@ -34,22 +36,35 @@ public final class PgpEncryptor {
*/
public byte[] encrypt(byte[] plaintext, Collection<OpenPGPCertificate> recipients,
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()) {
throw new IllegalArgumentException("At least one recipient is required");
}
EncryptionOptions options = EncryptionOptions.encryptCommunications(api);
recipients.forEach(options::addRecipient);
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);
try (EncryptionStream stream = api.generateMessage()
.onOutputStream(out)
.withOptions(ProducerOptions.encrypt(options)
.withOptions(producerOptions
.setAsciiArmor(true)
.setHideArmorHeaders(true))) {
stream.write(plaintext);
} catch (PGPException e) {
throw new IOException("OpenPGP encryption failed", e);
} catch (PGPException | RuntimeException e) {
throw new IOException("OpenPGP encryption failed: " + e.getMessage(), e);
}
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.List;
import java.util.Objects;
import java.util.Optional;
import java.util.function.Supplier;
import org.bouncycastle.openpgp.api.OpenPGPCertificate;
@@ -14,24 +15,49 @@ import jakarta.mail.MessagingException;
import jakarta.mail.internet.InternetAddress;
import jakarta.mail.internet.MimeMessage;
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 {
/** 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 SenderKeyProvider senderKeys;
private final MailSigning signing;
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.senderKeys = Objects.requireNonNull(senderKeys, "senderKeys");
this.signing = Objects.requireNonNull(signing, "signing");
}
/**
* @param newMessage creates empty messages bound to the sending session, e.g.
* {@code JavaMailSender::createMimeMessage}
*/
public List<MimeMessage> assemble(MimeMessage original, DeliveryPlan plan, Supplier<MimeMessage> newMessage)
public Assembly assemble(MimeMessage original, DeliveryPlan plan, Supplier<MimeMessage> newMessage)
throws MessagingException, IOException {
Optional<MessageSigner> signer = signing.signerFor(original);
List<OpenPGPCertificate> selfKeys = senderKey(original);
// All copies are the same message to their readers: same Date and Message-ID.
if (original.getSentDate() == null) {
@@ -40,20 +66,34 @@ public final class MessageAssembler {
original.saveChanges();
String messageId = original.getMessageID();
MimeMessage signedOriginal = null;
List<MimeMessage> messages = new ArrayList<>();
for (Delivery delivery : plan.deliveries()) {
MimeMessage message = original;
MimeMessage message;
if (delivery.encrypted()) {
List<OpenPGPCertificate> visible = new ArrayList<>(selfKeys);
List<OpenPGPCertificate> hidden = new ArrayList<>();
(delivery.hiddenRecipients() ? hidden : visible).addAll(delivery.recipientKeys());
message = mimeEncryptor.encrypt(original, visible, hidden, newMessage);
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());
copy.setHeader("Message-ID", messageId);
messages.add(copy);
}
return messages;
return new Assembly(messages, signer.map(MessageSigner::type).orElse(SignatureType.NONE));
}
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;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.Collection;
import java.util.Enumeration;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Properties;
import java.util.function.Supplier;
import org.bouncycastle.openpgp.api.OpenPGPCertificate;
import pro.thinhha.mailsender.pgp.crypto.PgpEncryptor;
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.MimeBodyPart;
import jakarta.mail.internet.MimeMessage;
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).
@@ -37,11 +28,8 @@ import jakarta.mail.util.ByteArrayDataSource;
*/
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);
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 boolean protectHeaders;
@@ -65,11 +53,19 @@ public final class PgpMimeEncryptor {
public MimeMessage encrypt(MimeMessage original, Collection<OpenPGPCertificate> recipients,
Collection<OpenPGPCertificate> hiddenRecipients, Supplier<MimeMessage> newMessage)
throws MessagingException, IOException {
MimeMessage source = snapshot(original);
byte[] ciphertext = encryptor.encrypt(innerEntity(source, protectHeaders), recipients, hiddenRecipients);
return encrypt(original, recipients, hiddenRecipients, newMessage, null);
}
/** @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();
copyEnvelopeHeaders(source, outer);
MimeEntities.copyEnvelopeHeaders(source, outer);
if (protectHeaders && outer.getHeader("Subject") != null) {
outer.setHeader("Subject", OBSCURED_SUBJECT);
}
@@ -78,73 +74,6 @@ public final class PgpMimeEncryptor {
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 {
MimeBodyPart control = new MimeBodyPart();
control.setDataHandler(new DataHandler(new ByteArrayDataSource(VERSION_PART, EncryptedMultipart.PROTOCOL)));
@@ -164,21 +93,4 @@ public final class PgpMimeEncryptor {
multipart.addBodyPart(encrypted);
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
* {@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);
return userIds.stream()
.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());
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).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();
}
}