From 7c2fb0142e26b952c9d1237bc977c1f8184f224c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 09:49:45 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01G71TQ5jDETVwdUJxGAwaiT --- README.md | 32 ++- docs/PLAN.md | 18 ++ .../src/main/resources/application.yml | 11 + mailsender-pgp-core/pom.xml | 27 ++- .../pgp/crypto/OpenPgpSigningKey.java | 23 ++ .../mailsender/pgp/crypto/PgpEncryptor.java | 21 +- .../mailsender/pgp/crypto/PgpSigner.java | 74 ++++++ .../pgp/delivery/MessageAssembler.java | 50 +++- .../mailsender/pgp/mime/MimeEntities.java | 143 ++++++++++++ .../mailsender/pgp/mime/PgpMimeEncryptor.java | 114 ++------- .../pgp/validation/CertificateValidator.java | 2 +- .../mailsender/signing/MailSigning.java | 61 +++++ .../signing/MailSigningException.java | 9 + .../mailsender/signing/MessageSigner.java | 24 ++ .../signing/OpenPgpMessageSigner.java | 101 ++++++++ .../mailsender/signing/SignatureType.java | 11 + .../mailsender/signing/SignerResolver.java | 29 +++ .../signing/SmimeMessageSigner.java | 209 +++++++++++++++++ .../pgp/delivery/MessageAssemblerTest.java | 2 +- .../signing/OpenPgpMessageSignerTest.java | 133 +++++++++++ .../signing/SigningTestSupport.java | 156 +++++++++++++ .../signing/SmimeMessageSignerTest.java | 108 +++++++++ .../pom.xml | 7 + .../PgpMailAutoConfiguration.java | 70 +++++- .../pgp/autoconfigure/PgpMailProperties.java | 39 ++++ .../pgp/metrics/MeteredSecureMailSender.java | 22 +- .../pgp/sender/BuiltInSecureMailSender.java | 9 +- .../pgp/sender/RelaySecureMailSender.java | 26 ++- .../mailsender/pgp/sender/SendReport.java | 15 +- .../metrics/MeteredSecureMailSenderTest.java | 5 +- .../pgp/sender/SigningIntegrationTest.java | 218 ++++++++++++++++++ pom.xml | 17 ++ 32 files changed, 1653 insertions(+), 133 deletions(-) create mode 100644 mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/crypto/OpenPgpSigningKey.java create mode 100644 mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/crypto/PgpSigner.java create mode 100644 mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/mime/MimeEntities.java create mode 100644 mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/signing/MailSigning.java create mode 100644 mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/signing/MailSigningException.java create mode 100644 mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/signing/MessageSigner.java create mode 100644 mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/signing/OpenPgpMessageSigner.java create mode 100644 mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/signing/SignatureType.java create mode 100644 mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/signing/SignerResolver.java create mode 100644 mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/signing/SmimeMessageSigner.java create mode 100644 mailsender-pgp-core/src/test/java/pro/thinhha/mailsender/signing/OpenPgpMessageSignerTest.java create mode 100644 mailsender-pgp-core/src/test/java/pro/thinhha/mailsender/signing/SigningTestSupport.java create mode 100644 mailsender-pgp-core/src/test/java/pro/thinhha/mailsender/signing/SmimeMessageSignerTest.java create mode 100644 mailsender-pgp-spring-boot-autoconfigure/src/test/java/pro/thinhha/mailsender/pgp/sender/SigningIntegrationTest.java diff --git a/README.md b/README.md index 38a0004..cac9711 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/PLAN.md b/docs/PLAN.md index bbc4e87..a9b9f14 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -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-`, 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. ✅ diff --git a/mailsender-demo/src/main/resources/application.yml b/mailsender-demo/src/main/resources/application.yml index d2dd7c6..295a2cf 100644 --- a/mailsender-demo/src/main/resources/application.yml +++ b/mailsender-demo/src/main/resources/application.yml @@ -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} diff --git a/mailsender-pgp-core/pom.xml b/mailsender-pgp-core/pom.xml index 490de98..745c908 100644 --- a/mailsender-pgp-core/pom.xml +++ b/mailsender-pgp-core/pom.xml @@ -11,7 +11,7 @@ mailsender-pgp-core - WKD key discovery, validation, OpenPGP encryption and PGP/MIME building (no Spring) + WKD key discovery, validation, OpenPGP encryption, PGP/MIME and S/MIME signing (no Spring) @@ -22,6 +22,15 @@ org.pgpainless wkd-java + + + org.bouncycastle + bcpkix-jdk18on + + + org.bouncycastle + bcjmail-jdk18on + com.github.ben-manes.caffeine caffeine @@ -52,4 +61,20 @@ test + + + + + org.apache.maven.plugins + maven-jar-plugin + + + + test-jar + + + + + + diff --git a/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/crypto/OpenPgpSigningKey.java b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/crypto/OpenPgpSigningKey.java new file mode 100644 index 0000000..1c007f1 --- /dev/null +++ b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/crypto/OpenPgpSigningKey.java @@ -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)))); + } +} diff --git a/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/crypto/PgpEncryptor.java b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/crypto/PgpEncryptor.java index 0cff0f2..7eb3fd1 100644 --- a/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/crypto/PgpEncryptor.java +++ b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/crypto/PgpEncryptor.java @@ -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 recipients, Collection 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 recipients, + Collection 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(); } diff --git a/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/crypto/PgpSigner.java b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/crypto/PgpSigner.java new file mode 100644 index 0000000..8a8f24f --- /dev/null +++ b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/crypto/PgpSigner.java @@ -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 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); + } +} diff --git a/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/MessageAssembler.java b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/MessageAssembler.java index 175e06e..f052b46 100644 --- a/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/MessageAssembler.java +++ b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/MessageAssembler.java @@ -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: + *
    + *
  • plaintext copies are {@code multipart/signed} (OpenPGP or S/MIME)
  • + *
  • encrypted copies are signed inside the encryption (OpenPGP), or S/MIME-signed and then encrypted
  • + *
+ */ public final class MessageAssembler { + /** The messages to send and how they were signed. */ + public record Assembly(List 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 assemble(MimeMessage original, DeliveryPlan plan, Supplier newMessage) + public Assembly assemble(MimeMessage original, DeliveryPlan plan, Supplier newMessage) throws MessagingException, IOException { + Optional signer = signing.signerFor(original); List 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 messages = new ArrayList<>(); for (Delivery delivery : plan.deliveries()) { - MimeMessage message = original; + MimeMessage message; if (delivery.encrypted()) { List visible = new ArrayList<>(selfKeys); List 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 senderKey(MimeMessage original) throws MessagingException { diff --git a/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/mime/MimeEntities.java b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/mime/MimeEntities.java new file mode 100644 index 0000000..7eac9bf --- /dev/null +++ b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/mime/MimeEntities.java @@ -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 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 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
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 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'); + } +} diff --git a/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/mime/PgpMimeEncryptor.java b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/mime/PgpMimeEncryptor.java index c07ed16..a47d5bb 100644 --- a/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/mime/PgpMimeEncryptor.java +++ b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/mime/PgpMimeEncryptor.java @@ -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 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 recipients, Collection hiddenRecipients, Supplier 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 recipients, + Collection hiddenRecipients, Supplier 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 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
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(); - } } diff --git a/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/validation/CertificateValidator.java b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/validation/CertificateValidator.java index cc7b4ab..7c353de 100644 --- a/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/validation/CertificateValidator.java +++ b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/validation/CertificateValidator.java @@ -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 } or the whole User ID. Comparison ignores case: WKD already lowercases the local part. */ - static boolean hasUserIdFor(List userIds, String email) { + public static boolean hasUserIdFor(List userIds, String email) { String wanted = email.strip().toLowerCase(Locale.ROOT); return userIds.stream() .map(CertificateValidator::addressOf) diff --git a/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/signing/MailSigning.java b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/signing/MailSigning.java new file mode 100644 index 0000000..12380ef --- /dev/null +++ b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/signing/MailSigning.java @@ -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 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 signer = signers.signerFor(sender.getAddress()); + return signer.isPresent() ? signer : missing("no signing key for " + sender.getAddress()); + } + + private Optional missing(String reason) { + if (required) { + throw new MailSigningException("Signing is required but " + reason); + } + log.warn("Sending UNSIGNED: {}", reason); + return Optional.empty(); + } +} diff --git a/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/signing/MailSigningException.java b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/signing/MailSigningException.java new file mode 100644 index 0000000..c893dfc --- /dev/null +++ b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/signing/MailSigningException.java @@ -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); + } +} diff --git a/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/signing/MessageSigner.java b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/signing/MessageSigner.java new file mode 100644 index 0000000..6fa63c0 --- /dev/null +++ b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/signing/MessageSigner.java @@ -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 newMessage) throws MessagingException, IOException; +} diff --git a/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/signing/OpenPgpMessageSigner.java b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/signing/OpenPgpMessageSigner.java new file mode 100644 index 0000000..ef95fce --- /dev/null +++ b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/signing/OpenPgpMessageSigner.java @@ -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. + *

+ * 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 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)); + } +} diff --git a/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/signing/SignatureType.java b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/signing/SignatureType.java new file mode 100644 index 0000000..98e4e4e --- /dev/null +++ b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/signing/SignatureType.java @@ -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 +} diff --git a/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/signing/SignerResolver.java b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/signing/SignerResolver.java new file mode 100644 index 0000000..c7b2b17 --- /dev/null +++ b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/signing/SignerResolver.java @@ -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 signerFor(String fromAddress); + + static SignerResolver none() { + return from -> Optional.empty(); + } + + /** Matches each signer by its {@link MessageSigner#address()}, ignoring case. */ + static SignerResolver of(List signers) { + Map 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))); + } +} diff --git a/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/signing/SmimeMessageSigner.java b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/signing/SmimeMessageSigner.java new file mode 100644 index 0000000..8e69b2b --- /dev/null +++ b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/signing/SmimeMessageSigner.java @@ -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. + *

+ * 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 chain; + private final String algorithm; + + /** @param chain the signer's certificate first, then intermediates */ + public SmimeMessageSigner(String address, PrivateKey privateKey, List chain) { + this(address, privateKey, chain, Clock.systemUTC()); + } + + SmimeMessageSigner(String address, PrivateKey privateKey, List 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 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> 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 aliases = store.aliases(); + while (aliases.hasMoreElements()) { + String alias = aliases.nextElement(); + if (store.isKeyEntry(alias)) { + return alias; + } + } + return null; + } +} diff --git a/mailsender-pgp-core/src/test/java/pro/thinhha/mailsender/pgp/delivery/MessageAssemblerTest.java b/mailsender-pgp-core/src/test/java/pro/thinhha/mailsender/pgp/delivery/MessageAssemblerTest.java index 22331fd..efb2599 100644 --- a/mailsender-pgp-core/src/test/java/pro/thinhha/mailsender/pgp/delivery/MessageAssemblerTest.java +++ b/mailsender-pgp-core/src/test/java/pro/thinhha/mailsender/pgp/delivery/MessageAssemblerTest.java @@ -49,7 +49,7 @@ class MessageAssemblerTest { Map.of("Sender@Example.com", SENDER.toCertificate()), new CertificateValidator()); List 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( diff --git a/mailsender-pgp-core/src/test/java/pro/thinhha/mailsender/signing/OpenPgpMessageSignerTest.java b/mailsender-pgp-core/src/test/java/pro/thinhha/mailsender/signing/OpenPgpMessageSignerTest.java new file mode 100644 index 0000000..18d4bf6 --- /dev/null +++ b/mailsender-pgp-core/src/test/java/pro/thinhha/mailsender/signing/OpenPgpMessageSignerTest.java @@ -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 "); + private static final OpenPGPKey PROTECTED = API.generateKey().modernKeyRing("App ", "s3cret"); + private static final OpenPGPKey ALICE = API.generateKey().modernKeyRing(""); + + 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; + } +} diff --git a/mailsender-pgp-core/src/test/java/pro/thinhha/mailsender/signing/SigningTestSupport.java b/mailsender-pgp-core/src/test/java/pro/thinhha/mailsender/signing/SigningTestSupport.java new file mode 100644 index 0000000..c6eb34b --- /dev/null +++ b/mailsender-pgp-core/src/test/java/pro/thinhha/mailsender/signing/SigningTestSupport.java @@ -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 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(); + } +} diff --git a/mailsender-pgp-core/src/test/java/pro/thinhha/mailsender/signing/SmimeMessageSignerTest.java b/mailsender-pgp-core/src/test/java/pro/thinhha/mailsender/signing/SmimeMessageSignerTest.java new file mode 100644 index 0000000..50e90e7 --- /dev/null +++ b/mailsender-pgp-core/src/test/java/pro/thinhha/mailsender/signing/SmimeMessageSignerTest.java @@ -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(); + } +} diff --git a/mailsender-pgp-spring-boot-autoconfigure/pom.xml b/mailsender-pgp-spring-boot-autoconfigure/pom.xml index b88e61e..ef43b4c 100644 --- a/mailsender-pgp-spring-boot-autoconfigure/pom.xml +++ b/mailsender-pgp-spring-boot-autoconfigure/pom.xml @@ -52,6 +52,13 @@ spring-boot-starter-test test + + pro.thinhha.mailsender + mailsender-pgp-core + ${project.version} + test-jar + test + org.springframework.boot spring-boot-starter-mail diff --git a/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailAutoConfiguration.java b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailAutoConfiguration.java index 433c89b..fa2f0e3 100644 --- a/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailAutoConfiguration.java +++ b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailAutoConfiguration.java @@ -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 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 mailSender, ObjectProvider planner, ObjectProvider assembler, - PolicyResolver policies, ObjectProvider customizers) { + PolicyResolver policies, MailSigning signing, ObjectProvider 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> readAll(String property, Map resources) { Map> certificates = new LinkedHashMap<>(); resources.forEach((email, resource) -> certificates.put(email, read(property, email, resource))); diff --git a/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailProperties.java b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailProperties.java index cadb51a..7cf0eb6 100644 --- a/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailProperties.java +++ b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailProperties.java @@ -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 staticKeys, Map 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 openpgp, + Map 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. diff --git a/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/metrics/MeteredSecureMailSender.java b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/metrics/MeteredSecureMailSender.java index 2d1d8c4..93de7da 100644 --- a/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/metrics/MeteredSecureMailSender.java +++ b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/metrics/MeteredSecureMailSender.java @@ -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) { diff --git a/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/sender/BuiltInSecureMailSender.java b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/sender/BuiltInSecureMailSender.java index c7798b4..c6dcbc3 100644 --- a/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/sender/BuiltInSecureMailSender.java +++ b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/sender/BuiltInSecureMailSender.java @@ -45,18 +45,19 @@ public final class BuiltInSecureMailSender extends AbstractSecureMailSender { recipients(message, Message.RecipientType.CC), recipients(message, Message.RecipientType.BCC)); - List 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 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()), diff --git a/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/sender/RelaySecureMailSender.java b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/sender/RelaySecureMailSender.java index a896ccf..cc52b8e 100644 --- a/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/sender/RelaySecureMailSender.java +++ b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/sender/RelaySecureMailSender.java @@ -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. + *

+ * 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 outcomes, List addresses, diff --git a/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/sender/SendReport.java b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/sender/SendReport.java index 399fc8f..ee4cfde 100644 --- a/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/sender/SendReport.java +++ b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/sender/SendReport.java @@ -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 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 recipients) { public SendReport { + signature = signature == null ? SignatureType.NONE : signature; recipients = List.copyOf(recipients); } + /** An unsigned message. */ + public SendReport(List recipients) { + this(SignatureType.NONE, recipients); + } + public boolean allEncrypted() { return recipients.stream().allMatch(r -> r.outcome() == Outcome.ENCRYPTED); } diff --git a/mailsender-pgp-spring-boot-autoconfigure/src/test/java/pro/thinhha/mailsender/pgp/metrics/MeteredSecureMailSenderTest.java b/mailsender-pgp-spring-boot-autoconfigure/src/test/java/pro/thinhha/mailsender/pgp/metrics/MeteredSecureMailSenderTest.java index b12d55b..d318ddc 100644 --- a/mailsender-pgp-spring-boot-autoconfigure/src/test/java/pro/thinhha/mailsender/pgp/metrics/MeteredSecureMailSenderTest.java +++ b/mailsender-pgp-spring-boot-autoconfigure/src/test/java/pro/thinhha/mailsender/pgp/metrics/MeteredSecureMailSenderTest.java @@ -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(); } diff --git a/mailsender-pgp-spring-boot-autoconfigure/src/test/java/pro/thinhha/mailsender/pgp/sender/SigningIntegrationTest.java b/mailsender-pgp-spring-boot-autoconfigure/src/test/java/pro/thinhha/mailsender/pgp/sender/SigningIntegrationTest.java new file mode 100644 index 0000000..c2afddd --- /dev/null +++ b/mailsender-pgp-spring-boot-autoconfigure/src/test/java/pro/thinhha/mailsender/pgp/sender/SigningIntegrationTest.java @@ -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-pass"); + private static final OpenPGPKey ALICE = API.generateKey().modernKeyRing(""); + + @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(); + } +} diff --git a/pom.xml b/pom.xml index db0f617..fb5bef2 100644 --- a/pom.xml +++ b/pom.xml @@ -26,6 +26,7 @@ 4.1.1 2.0.4 0.1.3 + 1.84 3.3.0 2.1.14 @@ -59,6 +60,17 @@ pgpainless-core ${pgpainless.version} + + + org.bouncycastle + bcpkix-jdk18on + ${bouncycastle.version} + + + org.bouncycastle + bcjmail-jdk18on + ${bouncycastle.version} + org.pgpainless wkd-java @@ -93,6 +105,11 @@ maven-surefire-plugin 3.5.3 + + org.apache.maven.plugins + maven-jar-plugin + 3.4.2 + org.apache.maven.plugins maven-deploy-plugin