hiddenRecipients) throws IOException {
+ if (recipients.isEmpty() && hiddenRecipients.isEmpty()) {
+ throw new IllegalArgumentException("At least one recipient is required");
+ }
+ EncryptionOptions options = EncryptionOptions.encryptCommunications(api);
+ recipients.forEach(options::addRecipient);
+ hiddenRecipients.forEach(options::addHiddenRecipient);
+
+ ByteArrayOutputStream out = new ByteArrayOutputStream(plaintext.length + 1024);
+ try (EncryptionStream stream = api.generateMessage()
+ .onOutputStream(out)
+ .withOptions(ProducerOptions.encrypt(options)
+ .setAsciiArmor(true)
+ .setHideArmorHeaders(true))) {
+ stream.write(plaintext);
+ } catch (PGPException e) {
+ throw new IOException("OpenPGP encryption failed", e);
+ }
+ return out.toByteArray();
+ }
+}
diff --git a/mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/CachingKeyDiscovery.java b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/discovery/CachingKeyDiscovery.java
similarity index 98%
rename from mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/CachingKeyDiscovery.java
rename to mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/discovery/CachingKeyDiscovery.java
index b29ea5b..e25d32f 100644
--- a/mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/CachingKeyDiscovery.java
+++ b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/discovery/CachingKeyDiscovery.java
@@ -1,4 +1,4 @@
-package io.github.sneakybugmaster.mailsender.pgp.discovery;
+package pro.thinhha.mailsender.pgp.discovery;
import java.time.Clock;
import java.time.Duration;
diff --git a/mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/Certificates.java b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/discovery/Certificates.java
similarity index 91%
rename from mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/Certificates.java
rename to mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/discovery/Certificates.java
index 14e1a88..e92a2b7 100644
--- a/mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/Certificates.java
+++ b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/discovery/Certificates.java
@@ -1,4 +1,4 @@
-package io.github.sneakybugmaster.mailsender.pgp.discovery;
+package pro.thinhha.mailsender.pgp.discovery;
import java.io.IOException;
import java.util.List;
diff --git a/mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/CompositeKeyDiscovery.java b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/discovery/CompositeKeyDiscovery.java
similarity index 95%
rename from mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/CompositeKeyDiscovery.java
rename to mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/discovery/CompositeKeyDiscovery.java
index b7f6dee..4773c54 100644
--- a/mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/CompositeKeyDiscovery.java
+++ b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/discovery/CompositeKeyDiscovery.java
@@ -1,4 +1,4 @@
-package io.github.sneakybugmaster.mailsender.pgp.discovery;
+package pro.thinhha.mailsender.pgp.discovery;
import java.util.List;
diff --git a/mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/DiscoveryResult.java b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/discovery/DiscoveryResult.java
similarity index 95%
rename from mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/DiscoveryResult.java
rename to mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/discovery/DiscoveryResult.java
index b822509..8737e67 100644
--- a/mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/DiscoveryResult.java
+++ b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/discovery/DiscoveryResult.java
@@ -1,4 +1,4 @@
-package io.github.sneakybugmaster.mailsender.pgp.discovery;
+package pro.thinhha.mailsender.pgp.discovery;
import java.util.List;
import java.util.Objects;
diff --git a/mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/DiscoverySource.java b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/discovery/DiscoverySource.java
similarity index 83%
rename from mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/DiscoverySource.java
rename to mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/discovery/DiscoverySource.java
index aa0aa71..78e5877 100644
--- a/mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/DiscoverySource.java
+++ b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/discovery/DiscoverySource.java
@@ -1,4 +1,4 @@
-package io.github.sneakybugmaster.mailsender.pgp.discovery;
+package pro.thinhha.mailsender.pgp.discovery;
/** Where a certificate came from. */
public enum DiscoverySource {
diff --git a/mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/HostResolver.java b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/discovery/HostResolver.java
similarity index 90%
rename from mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/HostResolver.java
rename to mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/discovery/HostResolver.java
index 50d368e..0a7758a 100644
--- a/mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/HostResolver.java
+++ b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/discovery/HostResolver.java
@@ -1,4 +1,4 @@
-package io.github.sneakybugmaster.mailsender.pgp.discovery;
+package pro.thinhha.mailsender.pgp.discovery;
import java.net.InetAddress;
import java.net.UnknownHostException;
diff --git a/mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/HttpWkdFetcher.java b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/discovery/HttpWkdFetcher.java
similarity index 97%
rename from mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/HttpWkdFetcher.java
rename to mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/discovery/HttpWkdFetcher.java
index f1e95fb..8046f9b 100644
--- a/mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/HttpWkdFetcher.java
+++ b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/discovery/HttpWkdFetcher.java
@@ -1,4 +1,4 @@
-package io.github.sneakybugmaster.mailsender.pgp.discovery;
+package pro.thinhha.mailsender.pgp.discovery;
import java.io.IOException;
import java.io.InputStream;
diff --git a/mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/KeyDiscovery.java b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/discovery/KeyDiscovery.java
similarity index 92%
rename from mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/KeyDiscovery.java
rename to mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/discovery/KeyDiscovery.java
index 5909517..3f2adef 100644
--- a/mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/KeyDiscovery.java
+++ b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/discovery/KeyDiscovery.java
@@ -1,4 +1,4 @@
-package io.github.sneakybugmaster.mailsender.pgp.discovery;
+package pro.thinhha.mailsender.pgp.discovery;
/**
* Finds the OpenPGP certificate(s) published for an e-mail address.
diff --git a/mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/StaticKeyDiscovery.java b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/discovery/StaticKeyDiscovery.java
similarity index 95%
rename from mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/StaticKeyDiscovery.java
rename to mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/discovery/StaticKeyDiscovery.java
index eb84c35..6f368c2 100644
--- a/mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/StaticKeyDiscovery.java
+++ b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/discovery/StaticKeyDiscovery.java
@@ -1,4 +1,4 @@
-package io.github.sneakybugmaster.mailsender.pgp.discovery;
+package pro.thinhha.mailsender.pgp.discovery;
import java.util.HashMap;
import java.util.List;
diff --git a/mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/WkdFallback.java b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/discovery/WkdFallback.java
similarity index 89%
rename from mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/WkdFallback.java
rename to mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/discovery/WkdFallback.java
index befffe2..94ff045 100644
--- a/mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/WkdFallback.java
+++ b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/discovery/WkdFallback.java
@@ -1,4 +1,4 @@
-package io.github.sneakybugmaster.mailsender.pgp.discovery;
+package pro.thinhha.mailsender.pgp.discovery;
/** When to fall back from the WKD advanced method to the direct method. */
public enum WkdFallback {
diff --git a/mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/WkdFetcher.java b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/discovery/WkdFetcher.java
similarity index 90%
rename from mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/WkdFetcher.java
rename to mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/discovery/WkdFetcher.java
index 41f216d..3041f08 100644
--- a/mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/WkdFetcher.java
+++ b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/discovery/WkdFetcher.java
@@ -1,4 +1,4 @@
-package io.github.sneakybugmaster.mailsender.pgp.discovery;
+package pro.thinhha.mailsender.pgp.discovery;
import java.io.IOException;
import java.net.URI;
diff --git a/mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/WkdKeyDiscovery.java b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/discovery/WkdKeyDiscovery.java
similarity index 98%
rename from mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/WkdKeyDiscovery.java
rename to mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/discovery/WkdKeyDiscovery.java
index 62042cd..4956fff 100644
--- a/mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/WkdKeyDiscovery.java
+++ b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/discovery/WkdKeyDiscovery.java
@@ -1,4 +1,4 @@
-package io.github.sneakybugmaster.mailsender.pgp.discovery;
+package pro.thinhha.mailsender.pgp.discovery;
import java.io.IOException;
import java.net.URI;
diff --git a/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/mime/EncryptedMultipart.java b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/mime/EncryptedMultipart.java
new file mode 100644
index 0000000..1efe75d
--- /dev/null
+++ b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/mime/EncryptedMultipart.java
@@ -0,0 +1,22 @@
+package pro.thinhha.mailsender.pgp.mime;
+
+import jakarta.mail.MessagingException;
+import jakarta.mail.internet.ContentType;
+import jakarta.mail.internet.MimeMultipart;
+
+/**
+ * {@code multipart/encrypted; protocol="application/pgp-encrypted"} (RFC 3156). A plain
+ * {@code new MimeMultipart("encrypted")} cannot carry the {@code protocol} parameter: {@code saveChanges()}
+ * regenerates the Content-Type from the multipart, so the parameter has to live in the multipart itself.
+ */
+final class EncryptedMultipart extends MimeMultipart {
+
+ static final String PROTOCOL = "application/pgp-encrypted";
+
+ EncryptedMultipart() throws MessagingException {
+ super("encrypted");
+ ContentType type = new ContentType(contentType);
+ type.setParameter("protocol", PROTOCOL);
+ contentType = type.toString();
+ }
+}
diff --git a/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/mime/PgpMimeEncryptor.java b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/mime/PgpMimeEncryptor.java
new file mode 100644
index 0000000..1dff6f8
--- /dev/null
+++ b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/mime/PgpMimeEncryptor.java
@@ -0,0 +1,138 @@
+package pro.thinhha.mailsender.pgp.mime;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Collection;
+import java.util.Enumeration;
+import java.util.Locale;
+import java.util.Objects;
+import java.util.Properties;
+import java.util.function.Supplier;
+
+import org.bouncycastle.openpgp.api.OpenPGPCertificate;
+
+import pro.thinhha.mailsender.pgp.crypto.PgpEncryptor;
+import jakarta.activation.DataHandler;
+import jakarta.mail.Header;
+import jakarta.mail.MessagingException;
+import jakarta.mail.Session;
+import jakarta.mail.internet.MimeBodyPart;
+import jakarta.mail.internet.MimeMessage;
+import jakarta.mail.util.ByteArrayDataSource;
+
+/**
+ * Turns a ready-to-send {@link MimeMessage} into a PGP/MIME message (RFC 3156).
+ *
+ * The whole inner MIME entity (its {@code Content-*} headers and body, including attachments) is encrypted, not
+ * just the text. Envelope headers (From, To, Cc, Subject, Date, ...) are copied to the outer message unencrypted.
+ */
+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 final PgpEncryptor encryptor;
+
+ public PgpMimeEncryptor(PgpEncryptor encryptor) {
+ this.encryptor = Objects.requireNonNull(encryptor, "encryptor");
+ }
+
+ /**
+ * @param original the message as it would be sent in plaintext; it is not modified
+ * @param recipients certificates to encrypt to (recipients, plus the sender's own key for a readable
+ * sent copy)
+ * @param hiddenRecipients certificates encrypted to without revealing their key ID (Bcc)
+ * @param newMessage creates the outer message, e.g. {@code JavaMailSender::createMimeMessage}
+ */
+ public MimeMessage encrypt(MimeMessage original, Collection recipients,
+ Collection hiddenRecipients, Supplier newMessage)
+ throws MessagingException, IOException {
+ MimeMessage source = snapshot(original);
+ byte[] ciphertext = encryptor.encrypt(innerEntity(source), recipients, hiddenRecipients);
+
+ MimeMessage outer = newMessage.get();
+ copyEnvelopeHeaders(source, outer);
+ outer.setContent(pgpMimeBody(ciphertext));
+ outer.saveChanges();
+ return outer;
+ }
+
+ /** Serializes and re-parses the message so headers are final and the encoded body is available. */
+ private static MimeMessage snapshot(MimeMessage original) throws MessagingException, IOException {
+ original.saveChanges();
+ ByteArrayOutputStream buffer = new ByteArrayOutputStream();
+ original.writeTo(buffer);
+ return new MimeMessage(PARSE_SESSION, new ByteArrayInputStream(buffer.toByteArray()));
+ }
+
+ /** The {@code Content-*} headers plus the still-encoded body, with CRLF line endings. */
+ static byte[] innerEntity(MimeMessage source) throws MessagingException, IOException {
+ ByteArrayOutputStream entity = new ByteArrayOutputStream();
+ Enumeration lines = source.getAllHeaderLines();
+ while (lines.hasMoreElements()) {
+ String line = lines.nextElement();
+ if (isContentHeader(line)) {
+ entity.write(line.getBytes(StandardCharsets.UTF_8));
+ entity.write('\r');
+ entity.write('\n');
+ }
+ }
+ entity.write('\r');
+ entity.write('\n');
+ try (InputStream body = source.getRawInputStream()) {
+ body.transferTo(entity);
+ }
+ return toCrlf(entity.toByteArray());
+ }
+
+ private static void copyEnvelopeHeaders(MimeMessage source, MimeMessage outer) throws MessagingException {
+ Enumeration headers = source.getAllHeaders();
+ while (headers.hasMoreElements()) {
+ Header header = headers.nextElement();
+ String name = header.getName().toLowerCase(Locale.ROOT);
+ if (!name.startsWith("content-") && !name.equals("mime-version") && !name.equals("message-id")) {
+ outer.addHeader(header.getName(), header.getValue());
+ }
+ }
+ }
+
+ private static EncryptedMultipart pgpMimeBody(byte[] ciphertext) throws MessagingException {
+ MimeBodyPart control = new MimeBodyPart();
+ control.setDataHandler(new DataHandler(new ByteArrayDataSource(VERSION_PART, EncryptedMultipart.PROTOCOL)));
+ control.setHeader("Content-Description", "PGP/MIME version identification");
+ control.setHeader("Content-Transfer-Encoding", "7bit");
+
+ MimeBodyPart encrypted = new MimeBodyPart();
+ encrypted.setDataHandler(new DataHandler(
+ new ByteArrayDataSource(ciphertext, "application/octet-stream; name=\"encrypted.asc\"")));
+ encrypted.setHeader("Content-Description", "OpenPGP encrypted message");
+ encrypted.setHeader("Content-Disposition", "inline; filename=\"encrypted.asc\"");
+ encrypted.setHeader("Content-Transfer-Encoding", "7bit");
+
+ EncryptedMultipart multipart = new EncryptedMultipart();
+ multipart.setPreamble("This is an OpenPGP/MIME encrypted message (RFC 4880 and 3156)");
+ multipart.addBodyPart(control);
+ multipart.addBodyPart(encrypted);
+ return multipart;
+ }
+
+ private static boolean isContentHeader(String headerLine) {
+ return headerLine.regionMatches(true, 0, "content-", 0, "content-".length());
+ }
+
+ /** RFC 3156 requires the encrypted entity in canonical form: every line ends in CRLF. */
+ static byte[] toCrlf(byte[] data) {
+ ByteArrayOutputStream out = new ByteArrayOutputStream(data.length + data.length / 50);
+ for (int i = 0; i < data.length; i++) {
+ byte b = data[i];
+ if (b == '\n' && (i == 0 || data[i - 1] != '\r')) {
+ out.write('\r');
+ }
+ out.write(b);
+ }
+ return out.toByteArray();
+ }
+}
diff --git a/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/validation/CertificateValidator.java b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/validation/CertificateValidator.java
new file mode 100644
index 0000000..cc7b4ab
--- /dev/null
+++ b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/validation/CertificateValidator.java
@@ -0,0 +1,100 @@
+package pro.thinhha.mailsender.pgp.validation;
+
+import java.time.Clock;
+import java.util.Date;
+import java.util.List;
+import java.util.Locale;
+import java.util.Objects;
+
+import org.bouncycastle.openpgp.api.OpenPGPCertificate;
+import org.bouncycastle.openpgp.api.OpenPGPCertificate.OpenPGPComponentKey;
+import org.pgpainless.PGPainless;
+import org.pgpainless.algorithm.EncryptionPurpose;
+import org.pgpainless.key.info.KeyRingInfo;
+import org.pgpainless.policy.Policy;
+
+/**
+ * Decides whether a discovered certificate may be used to encrypt mail to an address. A certificate is
+ * {@link ValidationResult.Valid} only if its primary key is neither revoked nor expired, a valid User ID contains
+ * the address, and at least one valid subkey for encrypting communications passes the algorithm policy.
+ */
+public final class CertificateValidator {
+
+ private final PGPainless api;
+ private final Clock clock;
+
+ public CertificateValidator() {
+ this(PGPainless.getInstance(), Clock.systemUTC());
+ }
+
+ public CertificateValidator(PGPainless api, Clock clock) {
+ this.api = Objects.requireNonNull(api, "api");
+ this.clock = Objects.requireNonNull(clock, "clock");
+ }
+
+ public ValidationResult validate(OpenPGPCertificate certificate, String email) {
+ Date now = Date.from(clock.instant());
+ try {
+ KeyRingInfo info = api.inspect(certificate, now);
+
+ // PGPainless and Bouncy Castle can disagree on expiry after it was changed; trust the earlier one.
+ Date expiration = earliest(info.getPrimaryKeyExpirationDate(), certificate.getExpirationTime(now));
+ if (expiration != null && !expiration.after(now)) {
+ return reject(certificate, ValidationResult.Reason.EXPIRED, "expired " + expiration.toInstant());
+ }
+ if (!info.getRevocationState().isNotRevoked() || !certificate.getPrimaryKey().isBoundAt(now)) {
+ return reject(certificate, ValidationResult.Reason.REVOKED, "primary key revoked");
+ }
+ if (!hasUserIdFor(info.getValidUserIds(), email)) {
+ return reject(certificate, ValidationResult.Reason.USER_ID_MISMATCH,
+ "no valid user ID for " + email + " in " + info.getUserIds());
+ }
+
+ List encryptionKeys = info.getEncryptionSubkeys(EncryptionPurpose.COMMUNICATIONS);
+ if (encryptionKeys.isEmpty()) {
+ return reject(certificate, ValidationResult.Reason.NO_ENCRYPTION_SUBKEY,
+ "no valid subkey for encrypting communications");
+ }
+ Policy.PublicKeyAlgorithmPolicy algorithmPolicy = api.getAlgorithmPolicy().getPublicKeyAlgorithmPolicy();
+ boolean acceptable = encryptionKeys.stream().anyMatch(key ->
+ algorithmPolicy.isAcceptable(key.getAlgorithm(), key.getPGPPublicKey().getBitStrength()));
+ if (!acceptable) {
+ return reject(certificate, ValidationResult.Reason.WEAK_ALGORITHM,
+ "encryption subkeys rejected by algorithm policy");
+ }
+ return new ValidationResult.Valid(certificate);
+ } catch (RuntimeException e) {
+ return reject(certificate, ValidationResult.Reason.INVALID, String.valueOf(e.getMessage()));
+ }
+ }
+
+ /**
+ * Matches {@code email} against the address in each User ID, either the {@code <...>} part of
+ * {@code Name } or the whole User ID. Comparison ignores case: WKD already lowercases the local part.
+ */
+ static boolean hasUserIdFor(List userIds, String email) {
+ String wanted = email.strip().toLowerCase(Locale.ROOT);
+ return userIds.stream()
+ .map(CertificateValidator::addressOf)
+ .anyMatch(wanted::equals);
+ }
+
+ private static String addressOf(String userId) {
+ int open = userId.lastIndexOf('<');
+ int close = userId.lastIndexOf('>');
+ String address = open >= 0 && close > open ? userId.substring(open + 1, close) : userId;
+ return address.strip().toLowerCase(Locale.ROOT);
+ }
+
+ private static Date earliest(Date a, Date b) {
+ if (a == null) {
+ return b;
+ }
+ return b == null || a.before(b) ? a : b;
+ }
+
+ private static ValidationResult reject(OpenPGPCertificate certificate, ValidationResult.Reason reason,
+ String detail) {
+ return new ValidationResult.Rejected(certificate, reason, detail);
+ }
+}
diff --git a/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/validation/ValidationResult.java b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/validation/ValidationResult.java
new file mode 100644
index 0000000..3ec8636
--- /dev/null
+++ b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/validation/ValidationResult.java
@@ -0,0 +1,40 @@
+package pro.thinhha.mailsender.pgp.validation;
+
+import java.util.Objects;
+
+import org.bouncycastle.openpgp.api.OpenPGPCertificate;
+
+/** Outcome of {@link CertificateValidator#validate}. */
+public sealed interface ValidationResult {
+
+ OpenPGPCertificate certificate();
+
+ /** The certificate can be used to encrypt mail to the address it was validated for. */
+ record Valid(OpenPGPCertificate certificate) implements ValidationResult {
+ public Valid {
+ Objects.requireNonNull(certificate, "certificate");
+ }
+ }
+
+ record Rejected(OpenPGPCertificate certificate, Reason reason, String detail) implements ValidationResult {
+ public Rejected {
+ Objects.requireNonNull(certificate, "certificate");
+ Objects.requireNonNull(reason, "reason");
+ }
+ }
+
+ enum Reason {
+ /** The primary key is revoked. */
+ REVOKED,
+ /** The primary key is expired. */
+ EXPIRED,
+ /** No valid User ID carries the address. */
+ USER_ID_MISMATCH,
+ /** No valid, non-expired subkey is marked for encrypting communications. */
+ NO_ENCRYPTION_SUBKEY,
+ /** Encryption subkeys exist, but their algorithm or size is rejected by the algorithm policy. */
+ WEAK_ALGORITHM,
+ /** The certificate could not be evaluated (bad self-signatures, unsupported packets, ...). */
+ INVALID
+ }
+}
diff --git a/mailsender-pgp-core/src/test/java/pro/thinhha/mailsender/pgp/crypto/PgpEncryptorTest.java b/mailsender-pgp-core/src/test/java/pro/thinhha/mailsender/pgp/crypto/PgpEncryptorTest.java
new file mode 100644
index 0000000..b9c9880
--- /dev/null
+++ b/mailsender-pgp-core/src/test/java/pro/thinhha/mailsender/pgp/crypto/PgpEncryptorTest.java
@@ -0,0 +1,57 @@
+package pro.thinhha.mailsender.pgp.crypto;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+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;
+
+public class PgpEncryptorTest {
+
+ private static final PGPainless API = PGPainless.getInstance();
+ private static final OpenPGPKey ALICE = API.generateKey().modernKeyRing("Alice ");
+ private static final OpenPGPKey BOB = API.generateKey().modernKeyRing("Bob ");
+ private static final OpenPGPKey CAROL = API.generateKey().modernKeyRing("Carol ");
+
+ private final PgpEncryptor encryptor = new PgpEncryptor();
+
+ @Test
+ void everyRecipientCanDecrypt() throws Exception {
+ byte[] plaintext = "Hello, PGP/MIME\r\n".getBytes(StandardCharsets.UTF_8);
+
+ byte[] armored = encryptor.encrypt(plaintext, List.of(ALICE.toCertificate(), BOB.toCertificate()),
+ List.of(CAROL.toCertificate()));
+
+ String text = new String(armored, StandardCharsets.US_ASCII);
+ assertThat(text).startsWith("-----BEGIN PGP MESSAGE-----").doesNotContain("Version:");
+ assertThat(decrypt(armored, ALICE)).isEqualTo(plaintext);
+ assertThat(decrypt(armored, BOB)).isEqualTo(plaintext);
+ assertThat(decrypt(armored, CAROL)).isEqualTo(plaintext);
+ }
+
+ @Test
+ void requiresARecipient() {
+ assertThatIllegalArgumentException().isThrownBy(() -> encryptor.encrypt(new byte[1], List.of(), List.of()));
+ }
+
+ public static byte[] decrypt(byte[] message, OpenPGPKey key) throws Exception {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ try (DecryptionStream in = API.processMessage()
+ .onInputStream(new ByteArrayInputStream(message))
+ .withOptions(ConsumerOptions.get(API).addDecryptionKey(key))) {
+ in.transferTo(out);
+ } catch (IOException e) {
+ throw e;
+ }
+ return out.toByteArray();
+ }
+}
diff --git a/mailsender-pgp-core/src/test/java/io/github/sneakybugmaster/mailsender/pgp/discovery/CachingKeyDiscoveryTest.java b/mailsender-pgp-core/src/test/java/pro/thinhha/mailsender/pgp/discovery/CachingKeyDiscoveryTest.java
similarity index 98%
rename from mailsender-pgp-core/src/test/java/io/github/sneakybugmaster/mailsender/pgp/discovery/CachingKeyDiscoveryTest.java
rename to mailsender-pgp-core/src/test/java/pro/thinhha/mailsender/pgp/discovery/CachingKeyDiscoveryTest.java
index 2b466e4..8f6b523 100644
--- a/mailsender-pgp-core/src/test/java/io/github/sneakybugmaster/mailsender/pgp/discovery/CachingKeyDiscoveryTest.java
+++ b/mailsender-pgp-core/src/test/java/pro/thinhha/mailsender/pgp/discovery/CachingKeyDiscoveryTest.java
@@ -1,4 +1,4 @@
-package io.github.sneakybugmaster.mailsender.pgp.discovery;
+package pro.thinhha.mailsender.pgp.discovery;
import static org.assertj.core.api.Assertions.assertThat;
diff --git a/mailsender-pgp-core/src/test/java/io/github/sneakybugmaster/mailsender/pgp/discovery/CompositeKeyDiscoveryTest.java b/mailsender-pgp-core/src/test/java/pro/thinhha/mailsender/pgp/discovery/CompositeKeyDiscoveryTest.java
similarity index 96%
rename from mailsender-pgp-core/src/test/java/io/github/sneakybugmaster/mailsender/pgp/discovery/CompositeKeyDiscoveryTest.java
rename to mailsender-pgp-core/src/test/java/pro/thinhha/mailsender/pgp/discovery/CompositeKeyDiscoveryTest.java
index 6172fab..de9ed7a 100644
--- a/mailsender-pgp-core/src/test/java/io/github/sneakybugmaster/mailsender/pgp/discovery/CompositeKeyDiscoveryTest.java
+++ b/mailsender-pgp-core/src/test/java/pro/thinhha/mailsender/pgp/discovery/CompositeKeyDiscoveryTest.java
@@ -1,4 +1,4 @@
-package io.github.sneakybugmaster.mailsender.pgp.discovery;
+package pro.thinhha.mailsender.pgp.discovery;
import static org.assertj.core.api.Assertions.assertThat;
diff --git a/mailsender-pgp-core/src/test/java/io/github/sneakybugmaster/mailsender/pgp/discovery/HttpWkdFetcherTest.java b/mailsender-pgp-core/src/test/java/pro/thinhha/mailsender/pgp/discovery/HttpWkdFetcherTest.java
similarity index 97%
rename from mailsender-pgp-core/src/test/java/io/github/sneakybugmaster/mailsender/pgp/discovery/HttpWkdFetcherTest.java
rename to mailsender-pgp-core/src/test/java/pro/thinhha/mailsender/pgp/discovery/HttpWkdFetcherTest.java
index a9519cd..8f31d3b 100644
--- a/mailsender-pgp-core/src/test/java/io/github/sneakybugmaster/mailsender/pgp/discovery/HttpWkdFetcherTest.java
+++ b/mailsender-pgp-core/src/test/java/pro/thinhha/mailsender/pgp/discovery/HttpWkdFetcherTest.java
@@ -1,4 +1,4 @@
-package io.github.sneakybugmaster.mailsender.pgp.discovery;
+package pro.thinhha.mailsender.pgp.discovery;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIOException;
diff --git a/mailsender-pgp-core/src/test/java/io/github/sneakybugmaster/mailsender/pgp/discovery/TestKeys.java b/mailsender-pgp-core/src/test/java/pro/thinhha/mailsender/pgp/discovery/TestKeys.java
similarity index 94%
rename from mailsender-pgp-core/src/test/java/io/github/sneakybugmaster/mailsender/pgp/discovery/TestKeys.java
rename to mailsender-pgp-core/src/test/java/pro/thinhha/mailsender/pgp/discovery/TestKeys.java
index 4748dc5..5570e2b 100644
--- a/mailsender-pgp-core/src/test/java/io/github/sneakybugmaster/mailsender/pgp/discovery/TestKeys.java
+++ b/mailsender-pgp-core/src/test/java/pro/thinhha/mailsender/pgp/discovery/TestKeys.java
@@ -1,4 +1,4 @@
-package io.github.sneakybugmaster.mailsender.pgp.discovery;
+package pro.thinhha.mailsender.pgp.discovery;
import java.io.IOException;
import java.io.UncheckedIOException;
diff --git a/mailsender-pgp-core/src/test/java/io/github/sneakybugmaster/mailsender/pgp/discovery/WkdAddressTest.java b/mailsender-pgp-core/src/test/java/pro/thinhha/mailsender/pgp/discovery/WkdAddressTest.java
similarity index 92%
rename from mailsender-pgp-core/src/test/java/io/github/sneakybugmaster/mailsender/pgp/discovery/WkdAddressTest.java
rename to mailsender-pgp-core/src/test/java/pro/thinhha/mailsender/pgp/discovery/WkdAddressTest.java
index 3265517..5a785fe 100644
--- a/mailsender-pgp-core/src/test/java/io/github/sneakybugmaster/mailsender/pgp/discovery/WkdAddressTest.java
+++ b/mailsender-pgp-core/src/test/java/pro/thinhha/mailsender/pgp/discovery/WkdAddressTest.java
@@ -1,4 +1,4 @@
-package io.github.sneakybugmaster.mailsender.pgp.discovery;
+package pro.thinhha.mailsender.pgp.discovery;
import static org.assertj.core.api.Assertions.assertThat;
diff --git a/mailsender-pgp-core/src/test/java/io/github/sneakybugmaster/mailsender/pgp/discovery/WkdKeyDiscoveryTest.java b/mailsender-pgp-core/src/test/java/pro/thinhha/mailsender/pgp/discovery/WkdKeyDiscoveryTest.java
similarity index 99%
rename from mailsender-pgp-core/src/test/java/io/github/sneakybugmaster/mailsender/pgp/discovery/WkdKeyDiscoveryTest.java
rename to mailsender-pgp-core/src/test/java/pro/thinhha/mailsender/pgp/discovery/WkdKeyDiscoveryTest.java
index 953600e..1ff9c57 100644
--- a/mailsender-pgp-core/src/test/java/io/github/sneakybugmaster/mailsender/pgp/discovery/WkdKeyDiscoveryTest.java
+++ b/mailsender-pgp-core/src/test/java/pro/thinhha/mailsender/pgp/discovery/WkdKeyDiscoveryTest.java
@@ -1,4 +1,4 @@
-package io.github.sneakybugmaster.mailsender.pgp.discovery;
+package pro.thinhha.mailsender.pgp.discovery;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
diff --git a/mailsender-pgp-core/src/test/java/pro/thinhha/mailsender/pgp/mime/PgpMimeEncryptorTest.java b/mailsender-pgp-core/src/test/java/pro/thinhha/mailsender/pgp/mime/PgpMimeEncryptorTest.java
new file mode 100644
index 0000000..987cab5
--- /dev/null
+++ b/mailsender-pgp-core/src/test/java/pro/thinhha/mailsender/pgp/mime/PgpMimeEncryptorTest.java
@@ -0,0 +1,136 @@
+package pro.thinhha.mailsender.pgp.mime;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Properties;
+
+import org.bouncycastle.openpgp.api.OpenPGPKey;
+import org.junit.jupiter.api.Test;
+import org.pgpainless.PGPainless;
+
+import pro.thinhha.mailsender.pgp.crypto.PgpEncryptor;
+import pro.thinhha.mailsender.pgp.crypto.PgpEncryptorTest;
+import jakarta.activation.DataHandler;
+import jakarta.mail.BodyPart;
+import jakarta.mail.Message;
+import jakarta.mail.Multipart;
+import jakarta.mail.Part;
+import jakarta.mail.Session;
+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;
+
+class PgpMimeEncryptorTest {
+
+ private static final OpenPGPKey ALICE =
+ PGPainless.getInstance().generateKey().modernKeyRing("Alice ");
+ private static final Session SESSION = Session.getInstance(new Properties());
+
+ private final PgpMimeEncryptor mimeEncryptor = new PgpMimeEncryptor(new PgpEncryptor());
+
+ @Test
+ void producesRfc3156StructureWithEnvelopeHeadersInTheClear() throws Exception {
+ MimeMessage wire = sendAndReceive(mimeEncryptor.encrypt(textMessage("Hi Alice\nsecond line"),
+ List.of(ALICE.toCertificate()), List.of(), () -> new MimeMessage(SESSION)));
+
+ ContentType type = new ContentType(wire.getContentType());
+ assertThat(type.getBaseType()).isEqualTo("multipart/encrypted");
+ assertThat(type.getParameter("protocol")).isEqualTo("application/pgp-encrypted");
+
+ assertThat(wire.getSubject()).isEqualTo("Quarterly numbers");
+ assertThat(wire.getFrom()).extracting(Object::toString).containsExactly("sender@example.com");
+ assertThat(wire.getRecipients(Message.RecipientType.TO)).extracting(Object::toString)
+ .containsExactly("alice@example.org");
+ assertThat(wire.getRecipients(Message.RecipientType.CC)).extracting(Object::toString)
+ .containsExactly("bob@example.org");
+
+ Multipart parts = (Multipart) wire.getContent();
+ assertThat(parts.getCount()).isEqualTo(2);
+ BodyPart control = parts.getBodyPart(0);
+ assertThat(new ContentType(control.getContentType()).getBaseType()).isEqualTo("application/pgp-encrypted");
+ assertThat(readAll(control)).isEqualTo("Version: 1\r\n");
+ BodyPart encrypted = parts.getBodyPart(1);
+ assertThat(new ContentType(encrypted.getContentType()).getBaseType()).isEqualTo("application/octet-stream");
+ assertThat(readAll(encrypted)).startsWith("-----BEGIN PGP MESSAGE-----");
+ assertThat(wire.getContent().toString()).doesNotContain("Hi Alice");
+ }
+
+ @Test
+ void encryptsWholeInnerEntityIncludingAttachments() throws Exception {
+ MimeMessage original = textMessage(null);
+ MimeMultipart mixed = new MimeMultipart("mixed");
+ MimeBodyPart text = new MimeBodyPart();
+ text.setText("See attached, Zoë.\nBye", "UTF-8");
+ MimeBodyPart attachment = new MimeBodyPart();
+ byte[] pdf = {'%', 'P', 'D', 'F', 0, 1, 2, (byte) 0xFF, '\n'};
+ attachment.setDataHandler(new DataHandler(new ByteArrayDataSource(pdf, "application/pdf")));
+ attachment.setFileName("report.pdf");
+ mixed.addBodyPart(text);
+ mixed.addBodyPart(attachment);
+ original.setContent(mixed);
+
+ MimeMessage wire = sendAndReceive(mimeEncryptor.encrypt(original, List.of(ALICE.toCertificate()), List.of(),
+ () -> new MimeMessage(SESSION)));
+
+ byte[] armored = readAll(((Multipart) wire.getContent()).getBodyPart(1)).getBytes(StandardCharsets.US_ASCII);
+ byte[] inner = PgpEncryptorTest.decrypt(armored, ALICE);
+ assertThat(hasBareLineFeed(inner)).as("inner entity is CRLF-canonical").isFalse();
+
+ MimeBodyPart entity = new MimeBodyPart(new ByteArrayInputStream(inner));
+ assertThat(entity.isMimeType("multipart/mixed")).isTrue();
+ Multipart decrypted = (Multipart) entity.getContent();
+ assertThat(decrypted.getCount()).isEqualTo(2);
+ assertThat(decrypted.getBodyPart(0).getContent().toString()).isEqualTo("See attached, Zoë.\r\nBye");
+ assertThat(decrypted.getBodyPart(1).getFileName()).isEqualTo("report.pdf");
+ assertThat(decrypted.getBodyPart(1).getInputStream().readAllBytes()).isEqualTo(pdf);
+ }
+
+ @Test
+ void leavesOriginalUntouched() throws Exception {
+ MimeMessage original = textMessage("plain");
+
+ mimeEncryptor.encrypt(original, List.of(ALICE.toCertificate()), List.of(), () -> new MimeMessage(SESSION));
+
+ assertThat(original.isMimeType("text/plain")).isTrue();
+ assertThat(original.getContent()).isEqualTo("plain");
+ }
+
+ private static MimeMessage textMessage(String text) throws Exception {
+ MimeMessage message = new MimeMessage(SESSION);
+ message.setFrom(new InternetAddress("sender@example.com"));
+ message.setRecipient(Message.RecipientType.TO, new InternetAddress("alice@example.org"));
+ message.setRecipient(Message.RecipientType.CC, new InternetAddress("bob@example.org"));
+ message.setSubject("Quarterly numbers");
+ if (text != null) {
+ message.setText(text, "UTF-8");
+ }
+ return message;
+ }
+
+ /** Serializes like SMTP would and parses the result like a receiving client. */
+ private static MimeMessage sendAndReceive(MimeMessage message) throws Exception {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ message.writeTo(out);
+ return new MimeMessage(SESSION, new ByteArrayInputStream(out.toByteArray()));
+ }
+
+ private static String readAll(Part part) throws Exception {
+ return new String(part.getInputStream().readAllBytes(), StandardCharsets.US_ASCII);
+ }
+
+ private static boolean hasBareLineFeed(byte[] data) {
+ for (int i = 0; i < data.length; i++) {
+ if (data[i] == '\n' && (i == 0 || data[i - 1] != '\r')) {
+ return true;
+ }
+ }
+ return false;
+ }
+}
diff --git a/mailsender-pgp-core/src/test/java/pro/thinhha/mailsender/pgp/validation/CertificateValidatorTest.java b/mailsender-pgp-core/src/test/java/pro/thinhha/mailsender/pgp/validation/CertificateValidatorTest.java
new file mode 100644
index 0000000..5705a37
--- /dev/null
+++ b/mailsender-pgp-core/src/test/java/pro/thinhha/mailsender/pgp/validation/CertificateValidatorTest.java
@@ -0,0 +1,102 @@
+package pro.thinhha.mailsender.pgp.validation;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.Date;
+import java.util.List;
+
+import org.bouncycastle.bcpg.KeyIdentifier;
+import org.bouncycastle.openpgp.api.OpenPGPCertificate;
+import org.bouncycastle.openpgp.api.OpenPGPKey;
+import org.junit.jupiter.api.Test;
+import org.pgpainless.PGPainless;
+import org.pgpainless.algorithm.EncryptionPurpose;
+import org.pgpainless.key.protection.SecretKeyRingProtector;
+
+class CertificateValidatorTest {
+
+ private static final String ALICE = "alice@example.org";
+ private static final SecretKeyRingProtector NO_PASSPHRASE = SecretKeyRingProtector.unprotectedKeys();
+
+ private final PGPainless api = PGPainless.getInstance();
+ private final CertificateValidator validator = new CertificateValidator();
+
+ @Test
+ void acceptsFreshModernKey() {
+ assertThat(validator.validate(key("Alice ").toCertificate(), ALICE))
+ .isInstanceOf(ValidationResult.Valid.class);
+ }
+
+ @Test
+ void matchesAddressCaseInsensitivelyAndBareUserIds() {
+ assertThat(validator.validate(key("Alice ").toCertificate(), ALICE))
+ .isInstanceOf(ValidationResult.Valid.class);
+ assertThat(validator.validate(key("alice@example.org").toCertificate(), ALICE))
+ .isInstanceOf(ValidationResult.Valid.class);
+ }
+
+ @Test
+ void rejectsUserIdForAnotherAddress() {
+ assertRejected(validator.validate(key("Eve ").toCertificate(), ALICE),
+ ValidationResult.Reason.USER_ID_MISMATCH);
+ // a look-alike must not match
+ assertRejected(validator.validate(key("Alice ").toCertificate(), ALICE),
+ ValidationResult.Reason.USER_ID_MISMATCH);
+ }
+
+ @Test
+ void rejectsRevokedUserId() throws Exception {
+ OpenPGPKey key = key("Alice ");
+ key = api.modify(key).addUserId("Alice ", NO_PASSPHRASE).done();
+ assertThat(validator.validate(key.toCertificate(), ALICE)).isInstanceOf(ValidationResult.Valid.class);
+
+ key = api.modify(key).revokeUserId("Alice ", NO_PASSPHRASE).done();
+
+ assertRejected(validator.validate(key.toCertificate(), ALICE), ValidationResult.Reason.USER_ID_MISMATCH);
+ }
+
+ @Test
+ void rejectsRevokedKey() throws Exception {
+ OpenPGPKey key = api.modify(key("Alice ")).revoke(NO_PASSPHRASE).done();
+
+ assertRejected(validator.validate(key.toCertificate(), ALICE), ValidationResult.Reason.REVOKED);
+ }
+
+ @Test
+ void rejectsExpiredKey() {
+ Instant now = Instant.now();
+ OpenPGPKey key = api.modify(key("Alice "))
+ .setExpirationDate(Date.from(now.plus(Duration.ofHours(1))), NO_PASSPHRASE)
+ .done();
+ CertificateValidator later = new CertificateValidator(api,
+ Clock.fixed(now.plus(Duration.ofHours(2)), ZoneOffset.UTC));
+
+ assertThat(validator.validate(key.toCertificate(), ALICE)).isInstanceOf(ValidationResult.Valid.class);
+ assertRejected(later.validate(key.toCertificate(), ALICE), ValidationResult.Reason.EXPIRED);
+ }
+
+ @Test
+ void rejectsKeyWhoseEncryptionSubkeyIsRevoked() throws Exception {
+ OpenPGPKey key = key("Alice ");
+ List encryptionKeys = api.inspect(key).getEncryptionSubkeys(EncryptionPurpose.COMMUNICATIONS)
+ .stream().map(OpenPGPCertificate.OpenPGPComponentKey::getKeyIdentifier).toList();
+ for (KeyIdentifier id : encryptionKeys) {
+ key = api.modify(key).revokeSubKey(id, NO_PASSPHRASE).done();
+ }
+
+ assertRejected(validator.validate(key.toCertificate(), ALICE), ValidationResult.Reason.NO_ENCRYPTION_SUBKEY);
+ }
+
+ private OpenPGPKey key(String userId) {
+ return api.generateKey().modernKeyRing(userId);
+ }
+
+ private static void assertRejected(ValidationResult result, ValidationResult.Reason reason) {
+ assertThat(result).isInstanceOfSatisfying(ValidationResult.Rejected.class,
+ rejected -> assertThat(rejected.reason()).isEqualTo(reason));
+ }
+}
diff --git a/mailsender-pgp-spring-boot-autoconfigure/pom.xml b/mailsender-pgp-spring-boot-autoconfigure/pom.xml
index 853366d..851b9e5 100644
--- a/mailsender-pgp-spring-boot-autoconfigure/pom.xml
+++ b/mailsender-pgp-spring-boot-autoconfigure/pom.xml
@@ -5,7 +5,7 @@
4.0.0
- io.github.sneakybugmaster.mailsender
+ pro.thinhha.mailsender
mailsender-parent
0.1.0-SNAPSHOT
@@ -14,7 +14,7 @@
- io.github.sneakybugmaster.mailsender
+ pro.thinhha.mailsender
mailsender-pgp-core
diff --git a/mailsender-pgp-spring-boot-autoconfigure/src/main/java/io/github/sneakybugmaster/mailsender/pgp/autoconfigure/PgpMailAutoConfiguration.java b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailAutoConfiguration.java
similarity index 70%
rename from mailsender-pgp-spring-boot-autoconfigure/src/main/java/io/github/sneakybugmaster/mailsender/pgp/autoconfigure/PgpMailAutoConfiguration.java
rename to mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailAutoConfiguration.java
index cfb66f7..111954b 100644
--- a/mailsender-pgp-spring-boot-autoconfigure/src/main/java/io/github/sneakybugmaster/mailsender/pgp/autoconfigure/PgpMailAutoConfiguration.java
+++ b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailAutoConfiguration.java
@@ -1,4 +1,4 @@
-package io.github.sneakybugmaster.mailsender.pgp.autoconfigure;
+package pro.thinhha.mailsender.pgp.autoconfigure;
import java.io.IOException;
import java.io.InputStream;
@@ -18,19 +18,23 @@ import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Primary;
import org.springframework.core.io.Resource;
-import io.github.sneakybugmaster.mailsender.pgp.discovery.CachingKeyDiscovery;
-import io.github.sneakybugmaster.mailsender.pgp.discovery.Certificates;
-import io.github.sneakybugmaster.mailsender.pgp.discovery.CompositeKeyDiscovery;
-import io.github.sneakybugmaster.mailsender.pgp.discovery.HostResolver;
-import io.github.sneakybugmaster.mailsender.pgp.discovery.HttpWkdFetcher;
-import io.github.sneakybugmaster.mailsender.pgp.discovery.KeyDiscovery;
-import io.github.sneakybugmaster.mailsender.pgp.discovery.StaticKeyDiscovery;
-import io.github.sneakybugmaster.mailsender.pgp.discovery.WkdFetcher;
-import io.github.sneakybugmaster.mailsender.pgp.discovery.WkdKeyDiscovery;
+import pro.thinhha.mailsender.pgp.crypto.PgpEncryptor;
+import pro.thinhha.mailsender.pgp.discovery.CachingKeyDiscovery;
+import pro.thinhha.mailsender.pgp.discovery.Certificates;
+import pro.thinhha.mailsender.pgp.discovery.CompositeKeyDiscovery;
+import pro.thinhha.mailsender.pgp.discovery.HostResolver;
+import pro.thinhha.mailsender.pgp.discovery.HttpWkdFetcher;
+import pro.thinhha.mailsender.pgp.discovery.KeyDiscovery;
+import pro.thinhha.mailsender.pgp.discovery.StaticKeyDiscovery;
+import pro.thinhha.mailsender.pgp.discovery.WkdFetcher;
+import pro.thinhha.mailsender.pgp.discovery.WkdKeyDiscovery;
+import pro.thinhha.mailsender.pgp.mime.PgpMimeEncryptor;
+import pro.thinhha.mailsender.pgp.validation.CertificateValidator;
/**
- * Key discovery beans: pinned keys first, then WKD behind a cache. The primary {@link KeyDiscovery} bean is the
- * combined chain; {@link CachingKeyDiscovery} is exposed separately so applications can evict entries.
+ * Key discovery (pinned keys first, then WKD behind a cache), certificate validation and PGP/MIME encryption beans.
+ * The primary {@link KeyDiscovery} bean is the combined chain; {@link CachingKeyDiscovery} is exposed separately so
+ * applications can evict entries.
*/
@AutoConfiguration
@EnableConfigurationProperties(PgpMailProperties.class)
@@ -75,6 +79,24 @@ public class PgpMailAutoConfiguration {
return new CompositeKeyDiscovery(chain);
}
+ @Bean
+ @ConditionalOnMissingBean
+ CertificateValidator pgpCertificateValidator() {
+ return new CertificateValidator();
+ }
+
+ @Bean
+ @ConditionalOnMissingBean
+ PgpEncryptor pgpEncryptor() {
+ return new PgpEncryptor();
+ }
+
+ @Bean
+ @ConditionalOnMissingBean
+ PgpMimeEncryptor pgpMimeEncryptor(PgpEncryptor encryptor) {
+ return new PgpMimeEncryptor(encryptor);
+ }
+
private static List read(String email, Resource resource) {
try (InputStream in = resource.getInputStream()) {
List certificates = Certificates.parse(in.readAllBytes());
diff --git a/mailsender-pgp-spring-boot-autoconfigure/src/main/java/io/github/sneakybugmaster/mailsender/pgp/autoconfigure/PgpMailProperties.java b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailProperties.java
similarity index 92%
rename from mailsender-pgp-spring-boot-autoconfigure/src/main/java/io/github/sneakybugmaster/mailsender/pgp/autoconfigure/PgpMailProperties.java
rename to mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailProperties.java
index a7119fa..fd04690 100644
--- a/mailsender-pgp-spring-boot-autoconfigure/src/main/java/io/github/sneakybugmaster/mailsender/pgp/autoconfigure/PgpMailProperties.java
+++ b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailProperties.java
@@ -1,4 +1,4 @@
-package io.github.sneakybugmaster.mailsender.pgp.autoconfigure;
+package pro.thinhha.mailsender.pgp.autoconfigure;
import java.time.Duration;
import java.util.Map;
@@ -8,7 +8,7 @@ import org.springframework.boot.context.properties.bind.DefaultValue;
import org.springframework.core.io.Resource;
import org.springframework.util.unit.DataSize;
-import io.github.sneakybugmaster.mailsender.pgp.discovery.WkdFallback;
+import pro.thinhha.mailsender.pgp.discovery.WkdFallback;
/**
* {@code mail.pgp.*} configuration.
diff --git a/mailsender-pgp-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/mailsender-pgp-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
index 60e0bac..6bf1061 100644
--- a/mailsender-pgp-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
+++ b/mailsender-pgp-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
@@ -1 +1 @@
-io.github.sneakybugmaster.mailsender.pgp.autoconfigure.PgpMailAutoConfiguration
+pro.thinhha.mailsender.pgp.autoconfigure.PgpMailAutoConfiguration
diff --git a/mailsender-pgp-spring-boot-autoconfigure/src/test/java/io/github/sneakybugmaster/mailsender/pgp/autoconfigure/PgpMailAutoConfigurationTest.java b/mailsender-pgp-spring-boot-autoconfigure/src/test/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailAutoConfigurationTest.java
similarity index 80%
rename from mailsender-pgp-spring-boot-autoconfigure/src/test/java/io/github/sneakybugmaster/mailsender/pgp/autoconfigure/PgpMailAutoConfigurationTest.java
rename to mailsender-pgp-spring-boot-autoconfigure/src/test/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailAutoConfigurationTest.java
index 731cbfd..0c02c2d 100644
--- a/mailsender-pgp-spring-boot-autoconfigure/src/test/java/io/github/sneakybugmaster/mailsender/pgp/autoconfigure/PgpMailAutoConfigurationTest.java
+++ b/mailsender-pgp-spring-boot-autoconfigure/src/test/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailAutoConfigurationTest.java
@@ -1,4 +1,4 @@
-package io.github.sneakybugmaster.mailsender.pgp.autoconfigure;
+package pro.thinhha.mailsender.pgp.autoconfigure;
import static org.assertj.core.api.Assertions.assertThat;
@@ -13,12 +13,14 @@ import org.pgpainless.PGPainless;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
-import io.github.sneakybugmaster.mailsender.pgp.discovery.CachingKeyDiscovery;
-import io.github.sneakybugmaster.mailsender.pgp.discovery.DiscoveryResult;
-import io.github.sneakybugmaster.mailsender.pgp.discovery.DiscoverySource;
-import io.github.sneakybugmaster.mailsender.pgp.discovery.KeyDiscovery;
-import io.github.sneakybugmaster.mailsender.pgp.discovery.WkdFallback;
-import io.github.sneakybugmaster.mailsender.pgp.discovery.WkdFetcher;
+import pro.thinhha.mailsender.pgp.discovery.CachingKeyDiscovery;
+import pro.thinhha.mailsender.pgp.discovery.DiscoveryResult;
+import pro.thinhha.mailsender.pgp.discovery.DiscoverySource;
+import pro.thinhha.mailsender.pgp.discovery.KeyDiscovery;
+import pro.thinhha.mailsender.pgp.discovery.WkdFallback;
+import pro.thinhha.mailsender.pgp.discovery.WkdFetcher;
+import pro.thinhha.mailsender.pgp.mime.PgpMimeEncryptor;
+import pro.thinhha.mailsender.pgp.validation.CertificateValidator;
class PgpMailAutoConfigurationTest {
@@ -31,7 +33,8 @@ class PgpMailAutoConfigurationTest {
@Test
void createsDiscoveryChainWithDefaults() {
runner.run(context -> {
- assertThat(context).hasSingleBean(WkdFetcher.class).hasSingleBean(CachingKeyDiscovery.class);
+ assertThat(context).hasSingleBean(WkdFetcher.class).hasSingleBean(CachingKeyDiscovery.class)
+ .hasSingleBean(CertificateValidator.class).hasSingleBean(PgpMimeEncryptor.class);
assertThat(context.getBean(KeyDiscovery.class)).isNotInstanceOf(CachingKeyDiscovery.class);
PgpMailProperties properties = context.getBean(PgpMailProperties.class);
diff --git a/mailsender-pgp-spring-boot-starter/pom.xml b/mailsender-pgp-spring-boot-starter/pom.xml
index 0710eae..fce3baf 100644
--- a/mailsender-pgp-spring-boot-starter/pom.xml
+++ b/mailsender-pgp-spring-boot-starter/pom.xml
@@ -5,7 +5,7 @@
4.0.0
- io.github.sneakybugmaster.mailsender
+ pro.thinhha.mailsender
mailsender-parent
0.1.0-SNAPSHOT
@@ -15,7 +15,7 @@
- io.github.sneakybugmaster.mailsender
+ pro.thinhha.mailsender
mailsender-pgp-spring-boot-autoconfigure
diff --git a/pom.xml b/pom.xml
index f952c16..037f2dc 100644
--- a/pom.xml
+++ b/pom.xml
@@ -4,7 +4,7 @@
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
4.0.0
- io.github.sneakybugmaster.mailsender
+ pro.thinhha.mailsender
mailsender-parent
0.1.0-SNAPSHOT
pom
@@ -40,17 +40,17 @@
import
- io.github.sneakybugmaster.mailsender
+ pro.thinhha.mailsender
mailsender-pgp-core
${project.version}
- io.github.sneakybugmaster.mailsender
+ pro.thinhha.mailsender
mailsender-pgp-spring-boot-autoconfigure
${project.version}
- io.github.sneakybugmaster.mailsender
+ pro.thinhha.mailsender
mailsender-pgp-spring-boot-starter
${project.version}