mirror of
https://github.com/sneakybugmaster/mailsender.git
synced 2026-11-10 19:48:49 +00:00
Add OpenPGP and S/MIME signing of outgoing mail
mail.pgp.signing.type selects NONE (default), OPENPGP or SMIME, with one
key/certificate per From address.
- OPENPGP: plaintext copies become PGP/MIME multipart/signed (RFC 3156 s5,
detached signature over the exact content entity bytes); encrypted copies
are signed inside the encryption (combined method)
- SMIME: plaintext copies become S/MIME multipart/signed (RFC 8551, via
Bouncy Castle bcjmail); encrypted copies are S/MIME-signed, then
PGP/MIME-encrypted
- signed bodies are attached as raw bytes so they reach the wire unchanged;
shared MIME helpers move to MimeEntities
- keys are validated at startup (signing capability, address match,
validity, passphrase / key-certificate match)
- mail.pgp.signing.required refuses mail from addresses without a key
(MailSigningException); RELAY mode signs before handing over
- SendReport.signature(), metric tag signature, refused{cause}
- core test-jar shares key/certificate and verification helpers
- end-to-end tests verify signatures on the bytes received over SMTP
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G71TQ5jDETVwdUJxGAwaiT
This commit is contained in:
32 files changed
+1653
-133
No files matched your search
@@ -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>
|
||||
+23
@@ -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))));
|
||||
}
|
||||
}
|
||||
+18
-3
@@ -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);
|
||||
}
|
||||
}
|
||||
+45
-5
@@ -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');
|
||||
}
|
||||
}
|
||||
+13
-101
@@ -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();
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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();
|
||||
}
|
||||
}
|
||||
+9
@@ -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;
|
||||
}
|
||||
+101
@@ -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)));
|
||||
}
|
||||
}
|
||||
+209
@@ -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;
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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(
|
||||
|
||||
+133
@@ -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;
|
||||
}
|
||||
}
|
||||
+156
@@ -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();
|
||||
}
|
||||
}
|
||||
+108
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user