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
@@ -14,7 +14,7 @@ Requires JDK 25 or newer; the Maven Wrapper (`./mvnw`) downloads Maven 3.9.11. B
|
||||
|
||||
| Module | Contents |
|
||||
|---|---|
|
||||
| `mailsender-pgp-core` | WKD key discovery, caching, pinned keys, certificate validation, OpenPGP encryption, PGP/MIME (no Spring) |
|
||||
| `mailsender-pgp-core` | WKD key discovery, caching, pinned keys, certificate validation, OpenPGP encryption, PGP/MIME, OpenPGP and S/MIME signing (no Spring) |
|
||||
| `mailsender-pgp-spring-boot-autoconfigure` | `mail.pgp.*` properties and beans |
|
||||
| `mailsender-pgp-spring-boot-starter` | dependency bundle for applications |
|
||||
| `mailsender-demo` | sample app that sends test mails |
|
||||
@@ -75,6 +75,36 @@ Map keys that are addresses need the `[...]` brackets. See [docs/PLAN.md](docs/P
|
||||
- With Actuator, expose `pgpkeys` to inspect an address (`GET /actuator/pgpkeys/{email}`) or evict cached keys
|
||||
(`DELETE /actuator/pgpkeys/{email}`).
|
||||
|
||||
## Signing
|
||||
|
||||
Outgoing mail can be signed with the From address's OpenPGP key or S/MIME certificate:
|
||||
|
||||
```yaml
|
||||
mail:
|
||||
pgp:
|
||||
signing:
|
||||
type: OPENPGP # NONE (default) | OPENPGP | SMIME
|
||||
required: false # true: refuse mail from an address without a signing key
|
||||
openpgp:
|
||||
"[noreply@example.com]":
|
||||
key: file:/etc/mail/noreply.sec.asc # secret key
|
||||
passphrase: ${SIGNING_PASSPHRASE}
|
||||
smime:
|
||||
"[noreply@example.com]":
|
||||
keystore: file:/etc/mail/noreply.p12 # PKCS#12: private key + certificate chain
|
||||
password: ${SMIME_PASSWORD}
|
||||
```
|
||||
|
||||
| | Plaintext copies | Encrypted copies |
|
||||
|---|---|---|
|
||||
| `OPENPGP` | PGP/MIME `multipart/signed` (RFC 3156) | signed inside the encryption |
|
||||
| `SMIME` | S/MIME `multipart/signed` (RFC 8551) | S/MIME-signed, then PGP/MIME-encrypted |
|
||||
|
||||
Keys are checked at startup: the key/certificate must belong to the address, be able to sign and be currently valid,
|
||||
and the passphrase must work. `SendReport.signature()` says how a message was signed. In RELAY mode the message is
|
||||
signed before it is handed to the gateway. Do not enable `mail.smtp.allow8bitmime` together with signing: re-encoding
|
||||
the body in transit invalidates signatures.
|
||||
|
||||
## Demo app
|
||||
|
||||
The demo app sends `DEMO_COUNT` test mails (default: 1 to `test@thinhha.pro`) through `SecureMailSender` and logs
|
||||
|
||||
@@ -172,6 +172,23 @@ Actuator is optional; when the `pgpkeys` endpoint is exposed
|
||||
|
||||
Extension points: `KeyDiscoveryCustomizer` (wraps WKD discovery below the cache) and `SecureMailSenderCustomizer`.
|
||||
|
||||
## Signing (M9)
|
||||
|
||||
`mail.pgp.signing.type`: `NONE` (default), `OPENPGP` or `SMIME`; one key per From address.
|
||||
|
||||
- **OPENPGP** — plaintext copies: `multipart/signed; protocol="application/pgp-signature"; micalg=pgp-<hash>`, detached
|
||||
signature over the exact bytes of the content entity (RFC 3156 section 5). Encrypted copies: signature inside the
|
||||
encryption (RFC 3156 section 6.2, combined method).
|
||||
- **SMIME** — plaintext copies: `multipart/signed; protocol="application/pkcs7-signature"` with the certificate chain
|
||||
(Bouncy Castle `bcjmail`, RFC 8551). Encrypted copies: the S/MIME-signed entity is PGP/MIME-encrypted.
|
||||
- Signed bodies are attached as raw bytes and the signed part is a parsed MIME part, so JavaMail writes them back byte
|
||||
for byte; tests verify signatures on the bytes received over SMTP, and the output was checked with `gpg --verify` and
|
||||
`openssl smime -verify`.
|
||||
- Keys are validated at startup (can sign, names the address, currently valid, passphrase/private key works).
|
||||
- `required=true` refuses mail from an address without a key (`MailSigningException`, metric
|
||||
`mail.pgp.refused{cause=signing_required}`); otherwise it is sent unsigned with a warning.
|
||||
- RELAY mode signs before handing the message to the gateway.
|
||||
|
||||
## Milestones
|
||||
1. **Skeleton** — parent POM, modules, CI (GitHub Actions, `mvn verify` on Temurin 25). ✅
|
||||
2. **WKD discovery + cache** — `WkdKeyDiscovery`, `HttpWkdFetcher`, `CachingKeyDiscovery`, static/composite discovery,
|
||||
@@ -193,3 +210,4 @@ Extension points: `KeyDiscoveryCustomizer` (wraps WKD discovery below the cache)
|
||||
`test@thinhha.pro` → found via advanced method, fpr `ADA6EFA0CC1630D602889820112495670FF1B4F0`.
|
||||
8. **Hardening** — Micrometer metrics, protected headers (RFC 9788, opt-in), actuator endpoint, docs. ✅
|
||||
Encrypting no longer regenerates the caller's Message-ID.
|
||||
9. **Signing** — OpenPGP and S/MIME signing, configurable per application. ✅
|
||||
@@ -24,6 +24,17 @@ mail:
|
||||
# Readable sent copies: the sender's own PUBLIC key per From address.
|
||||
# sender-keys:
|
||||
# "[noreply@example.com]": file:/etc/mail/noreply.pub.asc
|
||||
# Signing with the From address's key: NONE | OPENPGP | SMIME
|
||||
signing:
|
||||
type: ${MAIL_PGP_SIGNING_TYPE:NONE}
|
||||
# openpgp:
|
||||
# "[noreply@example.com]":
|
||||
# key: file:/etc/mail/noreply.sec.asc
|
||||
# passphrase: ${SIGNING_PASSPHRASE}
|
||||
# smime:
|
||||
# "[noreply@example.com]":
|
||||
# keystore: file:/etc/mail/noreply.p12
|
||||
# password: ${SMIME_PASSWORD}
|
||||
relay:
|
||||
host: ${RELAY_HOST:}
|
||||
port: ${RELAY_PORT:587}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,13 @@
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>pro.thinhha.mailsender</groupId>
|
||||
<artifactId>mailsender-pgp-core</artifactId>
|
||||
<version>${project.version}</version>
|
||||
<type>test-jar</type>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-mail</artifactId>
|
||||
|
||||
+66
-4
@@ -9,7 +9,11 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Properties;
|
||||
|
||||
import java.security.GeneralSecurityException;
|
||||
|
||||
import org.bouncycastle.openpgp.api.OpenPGPCertificate;
|
||||
import org.bouncycastle.openpgp.api.OpenPGPKey;
|
||||
import org.pgpainless.PGPainless;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
@@ -45,6 +49,11 @@ import pro.thinhha.mailsender.pgp.sender.BuiltInSecureMailSender;
|
||||
import pro.thinhha.mailsender.pgp.sender.RelaySecureMailSender;
|
||||
import pro.thinhha.mailsender.pgp.sender.SecureMailSender;
|
||||
import pro.thinhha.mailsender.pgp.validation.CertificateValidator;
|
||||
import pro.thinhha.mailsender.signing.MailSigning;
|
||||
import pro.thinhha.mailsender.signing.MessageSigner;
|
||||
import pro.thinhha.mailsender.signing.OpenPgpMessageSigner;
|
||||
import pro.thinhha.mailsender.signing.SignerResolver;
|
||||
import pro.thinhha.mailsender.signing.SmimeMessageSigner;
|
||||
|
||||
/**
|
||||
* Wires {@link SecureMailSender} and its parts: key discovery (pinned keys first, then WKD behind a cache),
|
||||
@@ -154,15 +163,40 @@ public class PgpMailAutoConfiguration {
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
MessageAssembler pgpMessageAssembler(PgpMimeEncryptor mimeEncryptor, SenderKeyProvider senderKeys) {
|
||||
return new MessageAssembler(mimeEncryptor, senderKeys);
|
||||
MailSigning pgpMailSigning(PgpMailProperties properties) {
|
||||
PgpMailProperties.Signing signing = properties.signing();
|
||||
List<MessageSigner> signers = new ArrayList<>();
|
||||
switch (signing.type()) {
|
||||
case NONE -> {
|
||||
if (!signing.openpgp().isEmpty() || !signing.smime().isEmpty()) {
|
||||
log.warn("mail.pgp.signing keys are configured but mail.pgp.signing.type=NONE: mail is not signed");
|
||||
}
|
||||
return MailSigning.disabled();
|
||||
}
|
||||
case OPENPGP -> signing.openpgp().forEach((address, key) -> signers.add(openPgpSigner(address, key)));
|
||||
case SMIME -> signing.smime().forEach((address, keystore) -> signers.add(smimeSigner(address, keystore)));
|
||||
}
|
||||
if (signers.isEmpty()) {
|
||||
throw new IllegalStateException("mail.pgp.signing.type=" + signing.type() + " but no mail.pgp.signing."
|
||||
+ signing.type().name().toLowerCase(java.util.Locale.ROOT) + "[address] key is configured");
|
||||
}
|
||||
log.info("Signing mail from {} with {}", signers.stream().map(MessageSigner::address).toList(),
|
||||
signing.type());
|
||||
return new MailSigning(SignerResolver.of(signers), signing.required());
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
MessageAssembler pgpMessageAssembler(PgpMimeEncryptor mimeEncryptor, SenderKeyProvider senderKeys,
|
||||
MailSigning signing) {
|
||||
return new MessageAssembler(mimeEncryptor, senderKeys, signing);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
SecureMailSender pgpSecureMailSender(PgpMailProperties properties, ObjectProvider<JavaMailSender> mailSender,
|
||||
ObjectProvider<DeliveryPlanner> planner, ObjectProvider<MessageAssembler> assembler,
|
||||
PolicyResolver policies, ObjectProvider<SecureMailSenderCustomizer> customizers) {
|
||||
PolicyResolver policies, MailSigning signing, ObjectProvider<SecureMailSenderCustomizer> customizers) {
|
||||
SecureMailSender sender = switch (properties.mode()) {
|
||||
case BUILT_IN -> {
|
||||
JavaMailSender delegate = mailSender.getIfAvailable();
|
||||
@@ -176,7 +210,7 @@ public class PgpMailAutoConfiguration {
|
||||
PgpMailProperties.Relay relay = properties.relay();
|
||||
log.info("mail.pgp.mode=RELAY: key discovery, encryption and policy enforcement are delegated to "
|
||||
+ "the gateway at {}:{}", relay.host(), relay.port());
|
||||
yield new RelaySecureMailSender(relaySender(relay), policies, relay.policyHeader());
|
||||
yield new RelaySecureMailSender(relaySender(relay), policies, relay.policyHeader(), signing);
|
||||
}
|
||||
};
|
||||
for (SecureMailSenderCustomizer customizer : customizers.orderedStream().toList()) {
|
||||
@@ -204,6 +238,34 @@ public class PgpMailAutoConfiguration {
|
||||
return sender;
|
||||
}
|
||||
|
||||
private static MessageSigner openPgpSigner(String address, PgpMailProperties.Signing.OpenPgp properties) {
|
||||
String property = "mail.pgp.signing.openpgp[" + address + "]";
|
||||
if (properties.key() == null) {
|
||||
throw new IllegalStateException(property + ".key is required");
|
||||
}
|
||||
try (InputStream in = properties.key().getInputStream()) {
|
||||
OpenPGPKey key = PGPainless.getInstance().readKey().parseKey(in.readAllBytes());
|
||||
char[] passphrase = properties.passphrase() == null ? null : properties.passphrase().toCharArray();
|
||||
return new OpenPgpMessageSigner(address, key, passphrase);
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(property + ": cannot read secret key " + properties.key(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private static MessageSigner smimeSigner(String address, PgpMailProperties.Signing.Smime properties) {
|
||||
String property = "mail.pgp.signing.smime[" + address + "]";
|
||||
if (properties.keystore() == null) {
|
||||
throw new IllegalStateException(property + ".keystore is required");
|
||||
}
|
||||
char[] password = properties.password() == null ? new char[0] : properties.password().toCharArray();
|
||||
try (InputStream in = properties.keystore().getInputStream()) {
|
||||
return SmimeMessageSigner.fromPkcs12(address, in, password, properties.alias());
|
||||
} catch (IOException | GeneralSecurityException e) {
|
||||
throw new IllegalStateException(property + ": cannot load keystore " + properties.keystore() + ": "
|
||||
+ e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private static Map<String, List<OpenPGPCertificate>> readAll(String property, Map<String, Resource> resources) {
|
||||
Map<String, List<OpenPGPCertificate>> certificates = new LinkedHashMap<>();
|
||||
resources.forEach((email, resource) -> certificates.put(email, read(property, email, resource)));
|
||||
|
||||
+39
@@ -11,6 +11,7 @@ import org.springframework.util.unit.DataSize;
|
||||
import pro.thinhha.mailsender.pgp.delivery.EncryptionPolicy;
|
||||
import pro.thinhha.mailsender.pgp.delivery.MixedMode;
|
||||
import pro.thinhha.mailsender.pgp.discovery.WkdFallback;
|
||||
import pro.thinhha.mailsender.signing.SignatureType;
|
||||
|
||||
/**
|
||||
* {@code mail.pgp.*} configuration.
|
||||
@@ -27,6 +28,7 @@ import pro.thinhha.mailsender.pgp.discovery.WkdFallback;
|
||||
* @param recipients policy rules per address or {@code *@domain}
|
||||
* @param staticKeys pinned recipient certificates per address, checked before WKD
|
||||
* @param senderKeys the sender's own public key per From address, so sent copies stay readable
|
||||
* @param signing signing of outgoing mail (OpenPGP or S/MIME)
|
||||
*/
|
||||
@ConfigurationProperties("mail.pgp")
|
||||
public record PgpMailProperties(
|
||||
@@ -40,6 +42,7 @@ public record PgpMailProperties(
|
||||
@DefaultValue Cache cache,
|
||||
Map<String, Resource> staticKeys,
|
||||
Map<String, Resource> senderKeys,
|
||||
@DefaultValue Signing signing,
|
||||
@DefaultValue Relay relay) {
|
||||
|
||||
public PgpMailProperties {
|
||||
@@ -75,6 +78,42 @@ public record PgpMailProperties(
|
||||
@DefaultValue("10000") long maxSize) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Signing of outgoing mail with the key/certificate of the From address.
|
||||
*
|
||||
* @param type {@code NONE}, {@code OPENPGP} or {@code SMIME}
|
||||
* @param required refuse to send ({@code MailSigningException}) when the From address has no signing key;
|
||||
* otherwise send unsigned and log a warning
|
||||
* @param openpgp OpenPGP secret key per From address (type {@code OPENPGP})
|
||||
* @param smime PKCS#12 keystore per From address (type {@code SMIME})
|
||||
*/
|
||||
public record Signing(
|
||||
@DefaultValue("NONE") SignatureType type,
|
||||
@DefaultValue("false") boolean required,
|
||||
Map<String, OpenPgp> openpgp,
|
||||
Map<String, Smime> smime) {
|
||||
|
||||
public Signing {
|
||||
openpgp = openpgp == null ? Map.of() : Map.copyOf(openpgp);
|
||||
smime = smime == null ? Map.of() : Map.copyOf(smime);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param key armored or binary OpenPGP secret key
|
||||
* @param passphrase passphrase of the key; empty for an unprotected key
|
||||
*/
|
||||
public record OpenPgp(Resource key, String passphrase) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param keystore PKCS#12 file with the private key and certificate chain
|
||||
* @param password keystore and key password
|
||||
* @param alias key entry to use; empty for the first key entry
|
||||
*/
|
||||
public record Smime(Resource keystore, String password, String alias) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SMTP connection to the encrypting gateway ({@code mode=RELAY}). The hop carries plaintext, so STARTTLS is
|
||||
* required by default.
|
||||
|
||||
+15
-7
@@ -11,10 +11,11 @@ import jakarta.mail.internet.MimeMessage;
|
||||
import pro.thinhha.mailsender.pgp.delivery.EncryptionPolicyException;
|
||||
import pro.thinhha.mailsender.pgp.sender.SecureMailSender;
|
||||
import pro.thinhha.mailsender.pgp.sender.SendReport;
|
||||
import pro.thinhha.mailsender.signing.MailSigningException;
|
||||
|
||||
/**
|
||||
* Counts per recipient {@code mail.pgp.recipients{outcome, policy, reason}} and refused messages
|
||||
* {@code mail.pgp.refused}. A plaintext recipient with policy {@code opportunistic} is a downgrade.
|
||||
* Counts per recipient {@code mail.pgp.recipients{outcome, policy, reason, signature}} and refused messages
|
||||
* {@code mail.pgp.refused{cause}}. A plaintext recipient with policy {@code opportunistic} is a downgrade.
|
||||
*/
|
||||
public final class MeteredSecureMailSender implements SecureMailSender {
|
||||
|
||||
@@ -39,7 +40,10 @@ public final class MeteredSecureMailSender implements SecureMailSender {
|
||||
try {
|
||||
return record(delegate.send(message));
|
||||
} catch (EncryptionPolicyException e) {
|
||||
refused();
|
||||
refused("encryption_policy");
|
||||
throw e;
|
||||
} catch (MailSigningException e) {
|
||||
refused("signing_required");
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
@@ -49,7 +53,10 @@ public final class MeteredSecureMailSender implements SecureMailSender {
|
||||
try {
|
||||
return record(delegate.send(message));
|
||||
} catch (EncryptionPolicyException e) {
|
||||
refused();
|
||||
refused("encryption_policy");
|
||||
throw e;
|
||||
} catch (MailSigningException e) {
|
||||
refused("signing_required");
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
@@ -61,15 +68,16 @@ public final class MeteredSecureMailSender implements SecureMailSender {
|
||||
.tag("outcome", lower(recipient.outcome()))
|
||||
.tag("policy", lower(recipient.policy()))
|
||||
.tag("reason", lower(recipient.reason()))
|
||||
.tag("signature", lower(report.signature()))
|
||||
.register(registry)
|
||||
.increment();
|
||||
}
|
||||
return report;
|
||||
}
|
||||
|
||||
private void refused() {
|
||||
Counter.builder(REFUSED).description("Messages refused by the encryption policy").register(registry)
|
||||
.increment();
|
||||
private void refused(String cause) {
|
||||
Counter.builder(REFUSED).description("Messages refused by the encryption or signing policy")
|
||||
.tag("cause", cause).register(registry).increment();
|
||||
}
|
||||
|
||||
private static String lower(Enum<?> value) {
|
||||
|
||||
+5
-4
@@ -45,18 +45,19 @@ public final class BuiltInSecureMailSender extends AbstractSecureMailSender {
|
||||
recipients(message, Message.RecipientType.CC),
|
||||
recipients(message, Message.RecipientType.BCC));
|
||||
|
||||
List<MimeMessage> copies;
|
||||
MessageAssembler.Assembly assembly;
|
||||
try {
|
||||
copies = assembler.assemble(message, plan, delegate::createMimeMessage);
|
||||
assembly = assembler.assemble(message, plan, delegate::createMimeMessage);
|
||||
} catch (MessagingException | IOException e) {
|
||||
throw new MailPreparationException("Could not build encrypted message", e);
|
||||
throw new MailPreparationException("Could not build signed/encrypted message", e);
|
||||
}
|
||||
List<MimeMessage> copies = assembly.messages();
|
||||
|
||||
plan.decisions().stream().filter(RecipientDecision::isDowngrade).forEach(d ->
|
||||
log.warn("Sending to {} in PLAINTEXT (policy {}): {}", d.address(), d.policy(), describe(d.key())));
|
||||
delegate.send(copies.toArray(MimeMessage[]::new));
|
||||
|
||||
SendReport report = new SendReport(plan.decisions().stream()
|
||||
SendReport report = new SendReport(assembly.signature(), plan.decisions().stream()
|
||||
.map(d -> new SendReport.RecipientOutcome(d.address(), d.type(), d.policy(),
|
||||
d.encrypted() ? SendReport.Outcome.ENCRYPTED : SendReport.Outcome.PLAINTEXT,
|
||||
d.encrypted() ? SendReport.PlaintextReason.NONE : reason(d.key()),
|
||||
|
||||
+25
-1
@@ -16,23 +16,36 @@ import jakarta.mail.internet.MimeMessage;
|
||||
import pro.thinhha.mailsender.pgp.delivery.EncryptionPolicy;
|
||||
import pro.thinhha.mailsender.pgp.delivery.PolicyResolver;
|
||||
import pro.thinhha.mailsender.pgp.delivery.RecipientType;
|
||||
import pro.thinhha.mailsender.signing.MailSigning;
|
||||
import pro.thinhha.mailsender.signing.MessageSigner;
|
||||
import pro.thinhha.mailsender.signing.SignatureType;
|
||||
|
||||
/**
|
||||
* Hands the plaintext message to an encrypting gateway (for example a GnuPG-based filter using
|
||||
* {@code --auto-key-locate wkd}). Key discovery, encryption and policy enforcement happen there; this sender can
|
||||
* only pass the strictest recipient policy along as a header hint.
|
||||
* <p>
|
||||
* If signing is configured, the message is signed here before it is handed over, so the gateway encrypts a signed
|
||||
* message.
|
||||
*/
|
||||
public final class RelaySecureMailSender extends AbstractSecureMailSender {
|
||||
|
||||
private final JavaMailSender relay;
|
||||
private final PolicyResolver policies;
|
||||
private final String policyHeader;
|
||||
private final MailSigning signing;
|
||||
|
||||
/** @param policyHeader header carrying the policy hint, or {@code null} for none */
|
||||
public RelaySecureMailSender(JavaMailSender relay, PolicyResolver policies, String policyHeader) {
|
||||
this(relay, policies, policyHeader, MailSigning.disabled());
|
||||
}
|
||||
|
||||
public RelaySecureMailSender(JavaMailSender relay, PolicyResolver policies, String policyHeader,
|
||||
MailSigning signing) {
|
||||
this.relay = Objects.requireNonNull(relay, "relay");
|
||||
this.policies = Objects.requireNonNull(policies, "policies");
|
||||
this.policyHeader = policyHeader == null || policyHeader.isBlank() ? null : policyHeader;
|
||||
this.signing = Objects.requireNonNull(signing, "signing");
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -47,6 +60,17 @@ public final class RelaySecureMailSender extends AbstractSecureMailSender {
|
||||
addOutcomes(outcomes, recipients(message, Message.RecipientType.CC), RecipientType.CC);
|
||||
addOutcomes(outcomes, recipients(message, Message.RecipientType.BCC), RecipientType.BCC);
|
||||
|
||||
SignatureType signature = SignatureType.NONE;
|
||||
try {
|
||||
MessageSigner signer = signing.signerFor(message).orElse(null);
|
||||
if (signer != null) {
|
||||
message = signer.sign(message, relay::createMimeMessage);
|
||||
signature = signer.type();
|
||||
}
|
||||
} catch (MessagingException | java.io.IOException e) {
|
||||
throw new MailPreparationException("Could not sign message", e);
|
||||
}
|
||||
|
||||
if (policyHeader != null && !outcomes.isEmpty()) {
|
||||
// Enum order is strictest first: REQUIRE, OPPORTUNISTIC, PLAINTEXT.
|
||||
EncryptionPolicy strictest = outcomes.stream().map(SendReport.RecipientOutcome::policy)
|
||||
@@ -58,7 +82,7 @@ public final class RelaySecureMailSender extends AbstractSecureMailSender {
|
||||
}
|
||||
}
|
||||
relay.send(message);
|
||||
return new SendReport(outcomes);
|
||||
return new SendReport(signature, outcomes);
|
||||
}
|
||||
|
||||
private void addOutcomes(List<SendReport.RecipientOutcome> outcomes, List<InternetAddress> addresses,
|
||||
|
||||
+13
-2
@@ -4,14 +4,25 @@ import java.util.List;
|
||||
|
||||
import pro.thinhha.mailsender.pgp.delivery.EncryptionPolicy;
|
||||
import pro.thinhha.mailsender.pgp.delivery.RecipientType;
|
||||
import pro.thinhha.mailsender.signing.SignatureType;
|
||||
|
||||
/** What happened to each recipient of a sent message. */
|
||||
public record SendReport(List<RecipientOutcome> recipients) {
|
||||
/**
|
||||
* What happened to each recipient of a sent message.
|
||||
*
|
||||
* @param signature how the message was signed; {@link SignatureType#NONE} if it was not
|
||||
*/
|
||||
public record SendReport(SignatureType signature, List<RecipientOutcome> recipients) {
|
||||
|
||||
public SendReport {
|
||||
signature = signature == null ? SignatureType.NONE : signature;
|
||||
recipients = List.copyOf(recipients);
|
||||
}
|
||||
|
||||
/** An unsigned message. */
|
||||
public SendReport(List<RecipientOutcome> recipients) {
|
||||
this(SignatureType.NONE, recipients);
|
||||
}
|
||||
|
||||
public boolean allEncrypted() {
|
||||
return recipients.stream().allMatch(r -> r.outcome() == Outcome.ENCRYPTED);
|
||||
}
|
||||
|
||||
+3
-2
@@ -42,7 +42,8 @@ class MeteredSecureMailSenderTest {
|
||||
|
||||
assertThatThrownBy(() -> new MeteredSecureMailSender(failing, registry).send(new SimpleMailMessage()))
|
||||
.isInstanceOf(EncryptionPolicyException.class);
|
||||
assertThat(registry.get(MeteredSecureMailSender.REFUSED).counter().count()).isEqualTo(1);
|
||||
assertThat(registry.get(MeteredSecureMailSender.REFUSED).tag("cause", "encryption_policy").counter().count())
|
||||
.isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -55,7 +56,7 @@ class MeteredSecureMailSenderTest {
|
||||
|
||||
private double count(String outcome, String reason) {
|
||||
return registry.get(MeteredSecureMailSender.RECIPIENTS)
|
||||
.tag("outcome", outcome).tag("policy", "opportunistic").tag("reason", reason)
|
||||
.tag("outcome", outcome).tag("policy", "opportunistic").tag("reason", reason).tag("signature", "none")
|
||||
.counter().count();
|
||||
}
|
||||
|
||||
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
package pro.thinhha.mailsender.pgp.sender;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.KeyStore;
|
||||
import java.security.cert.Certificate;
|
||||
|
||||
import org.bouncycastle.openpgp.api.OpenPGPKey;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.pgpainless.PGPainless;
|
||||
import org.pgpainless.decryption_verification.ConsumerOptions;
|
||||
import org.pgpainless.decryption_verification.DecryptionStream;
|
||||
import org.springframework.boot.autoconfigure.AutoConfigurations;
|
||||
import org.springframework.boot.mail.autoconfigure.MailSenderAutoConfiguration;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
import org.springframework.mail.SimpleMailMessage;
|
||||
|
||||
import com.icegreen.greenmail.util.GreenMail;
|
||||
import com.icegreen.greenmail.util.ServerSetupTest;
|
||||
|
||||
import jakarta.mail.Multipart;
|
||||
import jakarta.mail.internet.MimeBodyPart;
|
||||
import jakarta.mail.internet.MimeMessage;
|
||||
import pro.thinhha.mailsender.pgp.autoconfigure.PgpMailAutoConfiguration;
|
||||
import pro.thinhha.mailsender.signing.MailSigningException;
|
||||
import pro.thinhha.mailsender.signing.SignatureType;
|
||||
import pro.thinhha.mailsender.signing.SigningTestSupport;
|
||||
|
||||
/**
|
||||
* Signing end to end: auto-configured {@link SecureMailSender} → SMTP (GreenMail) → verification on the received
|
||||
* bytes, for plaintext and encrypted copies.
|
||||
*/
|
||||
class SigningIntegrationTest {
|
||||
|
||||
private static final PGPainless API = PGPainless.getInstance();
|
||||
private static final OpenPGPKey APP = API.generateKey().modernKeyRing("App <app@example.com>", "app-pass");
|
||||
private static final OpenPGPKey ALICE = API.generateKey().modernKeyRing("<alice@example.org>");
|
||||
|
||||
@TempDir
|
||||
Path dir;
|
||||
private GreenMail greenMail;
|
||||
private ApplicationContextRunner runner;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
greenMail = new GreenMail(ServerSetupTest.SMTP.dynamicPort());
|
||||
greenMail.start();
|
||||
Path alice = dir.resolve("alice.asc");
|
||||
Files.writeString(alice, ALICE.toCertificate().toAsciiArmoredString());
|
||||
runner = new ApplicationContextRunner()
|
||||
.withConfiguration(AutoConfigurations.of(MailSenderAutoConfiguration.class,
|
||||
PgpMailAutoConfiguration.class))
|
||||
.withPropertyValues(
|
||||
"spring.mail.host=127.0.0.1",
|
||||
"spring.mail.port=" + greenMail.getSmtp().getPort(),
|
||||
"mail.pgp.wkd.enabled=false",
|
||||
"mail.pgp.static-keys[alice@example.org]=file:" + alice);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
greenMail.stop();
|
||||
}
|
||||
|
||||
@Test
|
||||
void openPgpSignsPlaintextAndEncryptedCopies() throws Exception {
|
||||
Path key = dir.resolve("app.sec.asc");
|
||||
Files.writeString(key, APP.toAsciiArmoredString());
|
||||
|
||||
runner.withPropertyValues(
|
||||
"mail.pgp.signing.type=OPENPGP",
|
||||
"mail.pgp.signing.openpgp[app@example.com].key=file:" + key,
|
||||
"mail.pgp.signing.openpgp[app@example.com].passphrase=app-pass")
|
||||
.run(context -> {
|
||||
SendReport report = context.getBean(SecureMailSender.class).send(mixedMessage());
|
||||
|
||||
assertThat(report.signature()).isEqualTo(SignatureType.OPENPGP);
|
||||
MimeMessage carol = inbox("carol@example.org");
|
||||
assertThat(SigningTestSupport.verifyOpenPgp(carol, APP.toCertificate())).isTrue();
|
||||
|
||||
MimeMessage alice = inbox("alice@example.org");
|
||||
assertThat(alice.isMimeType("multipart/encrypted")).isTrue();
|
||||
byte[] armored = ((Multipart) alice.getContent()).getBodyPart(1).getInputStream().readAllBytes();
|
||||
DecryptionStream in = API.processMessage().onInputStream(new ByteArrayInputStream(armored))
|
||||
.withOptions(ConsumerOptions.get(API).addDecryptionKey(ALICE)
|
||||
.addVerificationCert(APP.toCertificate()));
|
||||
ByteArrayOutputStream inner = new ByteArrayOutputStream();
|
||||
in.transferTo(inner);
|
||||
in.close();
|
||||
assertThat(in.getMetadata().isVerifiedSignedBy(APP.toCertificate())).isTrue();
|
||||
assertThat(inner.toString()).contains("hello");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void smimeSignsPlaintextAndEncryptedCopies() throws Exception {
|
||||
SigningTestSupport.SmimeIdentity identity = SigningTestSupport.smimeIdentity("app@example.com");
|
||||
Path p12 = dir.resolve("app.p12");
|
||||
KeyStore store = KeyStore.getInstance("PKCS12");
|
||||
store.load(null, null);
|
||||
store.setKeyEntry("app", identity.keyPair().getPrivate(), "pw".toCharArray(),
|
||||
new Certificate[] {identity.certificate()});
|
||||
try (var out = Files.newOutputStream(p12)) {
|
||||
store.store(out, "pw".toCharArray());
|
||||
}
|
||||
|
||||
runner.withPropertyValues(
|
||||
"mail.pgp.signing.type=SMIME",
|
||||
"mail.pgp.signing.smime[app@example.com].keystore=file:" + p12,
|
||||
"mail.pgp.signing.smime[app@example.com].password=pw")
|
||||
.run(context -> {
|
||||
SendReport report = context.getBean(SecureMailSender.class).send(mixedMessage());
|
||||
|
||||
assertThat(report.signature()).isEqualTo(SignatureType.SMIME);
|
||||
assertThat(SigningTestSupport.verifySmime(inbox("carol@example.org"), identity.certificate()))
|
||||
.isTrue();
|
||||
|
||||
// encrypted copy: PGP/MIME around an S/MIME-signed entity
|
||||
MimeMessage alice = inbox("alice@example.org");
|
||||
byte[] armored = ((Multipart) alice.getContent()).getBodyPart(1).getInputStream().readAllBytes();
|
||||
ByteArrayOutputStream inner = new ByteArrayOutputStream();
|
||||
try (DecryptionStream in = API.processMessage().onInputStream(new ByteArrayInputStream(armored))
|
||||
.withOptions(ConsumerOptions.get(API).addDecryptionKey(ALICE))) {
|
||||
in.transferTo(inner);
|
||||
}
|
||||
MimeMessage decrypted = new MimeMessage(SigningTestSupport.SESSION,
|
||||
new ByteArrayInputStream(inner.toByteArray()));
|
||||
assertThat(SigningTestSupport.verifySmime(decrypted, identity.certificate())).isTrue();
|
||||
MimeBodyPart content = (MimeBodyPart) ((Multipart) decrypted.getContent()).getBodyPart(0);
|
||||
assertThat(content.getContent().toString()).contains("hello");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void requiredSigningRefusesSenderWithoutKey() throws Exception {
|
||||
Path key = dir.resolve("app.sec.asc");
|
||||
Files.writeString(key, APP.toAsciiArmoredString());
|
||||
|
||||
runner.withPropertyValues(
|
||||
"mail.pgp.signing.type=OPENPGP",
|
||||
"mail.pgp.signing.required=true",
|
||||
"mail.pgp.signing.openpgp[app@example.com].key=file:" + key,
|
||||
"mail.pgp.signing.openpgp[app@example.com].passphrase=app-pass")
|
||||
.run(context -> {
|
||||
SimpleMailMessage message = new SimpleMailMessage();
|
||||
message.setFrom("other@example.com");
|
||||
message.setTo("carol@example.org");
|
||||
message.setText("x");
|
||||
|
||||
assertThatThrownBy(() -> context.getBean(SecureMailSender.class).send(message))
|
||||
.isInstanceOf(MailSigningException.class).hasMessageContaining("other@example.com");
|
||||
assertThat(greenMail.getReceivedMessages()).isEmpty();
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void relayModeSignsBeforeHandingOver() throws Exception {
|
||||
Path key = dir.resolve("app.sec.asc");
|
||||
Files.writeString(key, APP.toAsciiArmoredString());
|
||||
|
||||
runner.withPropertyValues(
|
||||
"mail.pgp.mode=RELAY",
|
||||
"mail.pgp.relay.host=127.0.0.1",
|
||||
"mail.pgp.relay.port=" + greenMail.getSmtp().getPort(),
|
||||
"mail.pgp.relay.starttls=false",
|
||||
"mail.pgp.signing.type=OPENPGP",
|
||||
"mail.pgp.signing.openpgp[app@example.com].key=file:" + key,
|
||||
"mail.pgp.signing.openpgp[app@example.com].passphrase=app-pass")
|
||||
.run(context -> {
|
||||
SendReport report = context.getBean(SecureMailSender.class).send(mixedMessage());
|
||||
|
||||
assertThat(report.signature()).isEqualTo(SignatureType.OPENPGP);
|
||||
assertThat(SigningTestSupport.verifyOpenPgp(inbox("carol@example.org"), APP.toCertificate()))
|
||||
.isTrue();
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void startupFailsOnWrongPassphraseOrMissingKeys() throws Exception {
|
||||
Path key = dir.resolve("app.sec.asc");
|
||||
Files.writeString(key, APP.toAsciiArmoredString());
|
||||
|
||||
runner.withPropertyValues(
|
||||
"mail.pgp.signing.type=OPENPGP",
|
||||
"mail.pgp.signing.openpgp[app@example.com].key=file:" + key,
|
||||
"mail.pgp.signing.openpgp[app@example.com].passphrase=wrong")
|
||||
.run(context -> assertThat(context).hasFailed().getFailure()
|
||||
.hasStackTraceContaining("wrong passphrase?"));
|
||||
runner.withPropertyValues("mail.pgp.signing.type=SMIME")
|
||||
.run(context -> assertThat(context).hasFailed().getFailure()
|
||||
.rootCause().hasMessageContaining("no mail.pgp.signing.smime[address] key"));
|
||||
}
|
||||
|
||||
private static SimpleMailMessage mixedMessage() {
|
||||
SimpleMailMessage message = new SimpleMailMessage();
|
||||
message.setFrom("app@example.com");
|
||||
message.setTo("alice@example.org", "carol@example.org");
|
||||
message.setSubject("Signed");
|
||||
message.setText("hello, Grüße");
|
||||
return message;
|
||||
}
|
||||
|
||||
private MimeMessage inbox(String address) throws Exception {
|
||||
var user = greenMail.getUserManager().getUserByEmail(address);
|
||||
assertThat(user).as("mailbox for " + address).isNotNull();
|
||||
var stored = greenMail.getManagers().getImapHostManager().getInbox(user).getMessages();
|
||||
assertThat(stored).hasSize(1);
|
||||
return stored.getFirst().getMimeMessage();
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@
|
||||
<spring-boot.version>4.1.1</spring-boot.version>
|
||||
<pgpainless.version>2.0.4</pgpainless.version>
|
||||
<wkd-java.version>0.1.3</wkd-java.version>
|
||||
<bouncycastle.version>1.84</bouncycastle.version>
|
||||
<caffeine.version>3.3.0</caffeine.version>
|
||||
<greenmail.version>2.1.14</greenmail.version>
|
||||
</properties>
|
||||
@@ -59,6 +60,17 @@
|
||||
<artifactId>pgpainless-core</artifactId>
|
||||
<version>${pgpainless.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<!-- same Bouncy Castle release as PGPainless uses -->
|
||||
<groupId>org.bouncycastle</groupId>
|
||||
<artifactId>bcpkix-jdk18on</artifactId>
|
||||
<version>${bouncycastle.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.bouncycastle</groupId>
|
||||
<artifactId>bcjmail-jdk18on</artifactId>
|
||||
<version>${bouncycastle.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.pgpainless</groupId>
|
||||
<artifactId>wkd-java</artifactId>
|
||||
@@ -93,6 +105,11 @@
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>3.5.3</version>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-jar-plugin</artifactId>
|
||||
<version>3.4.2</version>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-deploy-plugin</artifactId>
|
||||
|
||||
Reference in new issue
Block a user