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