diff --git a/README.md b/README.md index 80cbb97..cecda2a 100644 --- a/README.md +++ b/README.md @@ -14,16 +14,17 @@ mvn verify | Module | Contents | |---|---| -| `mailsender-pgp-core` | WKD key discovery, caching, pinned keys (no Spring) | -| `mailsender-pgp-spring-boot-autoconfigure` | `mail.pgp.*` properties and discovery beans | +| `mailsender-pgp-core` | WKD key discovery, caching, pinned keys, certificate validation, OpenPGP encryption, PGP/MIME (no Spring) | +| `mailsender-pgp-spring-boot-autoconfigure` | `mail.pgp.*` properties and beans | | `mailsender-pgp-spring-boot-starter` | dependency bundle for applications | | `mailsender-demo` | sample app that sends test mails | ## Demo app -The demo app looks up the recipient's key via WKD, logs the result, and sends `DEMO_COUNT` test mails -(default: 1 to `test@thinhha.pro`). Encryption is not wired in yet, so the mails are sent **in plaintext**, -and the body says whether a key was found. +The demo app looks up and validates the recipient's key via WKD, then sends `DEMO_COUNT` test mails +(default: 1 to `test@thinhha.pro`). With a valid key the mail is PGP/MIME-encrypted (RFC 3156); otherwise it is +sent in plaintext and a warning is logged. The policy layer (REQUIRE / OPPORTUNISTIC / PLAINTEXT, split +delivery) comes in milestone 5. ``` mvn -q install -DskipTests diff --git a/docs/PLAN.md b/docs/PLAN.md index 86c6b90..2219882 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -164,12 +164,14 @@ mail.pgp: 2. **WKD discovery + cache** — `WkdKeyDiscovery`, `HttpWkdFetcher`, `CachingKeyDiscovery`, static/composite discovery, properties + autoconfig for discovery beans. Tests: z-base-32 vector, advanced/direct fallback matrix, size limit, no redirects, cache TTLs. ✅ -3. **Validation** — generated test keys: expired, revoked, sign-only, wrong UID, valid. -4. **Encryptor + PGP/MIME** — round-trip decrypt test, golden structure test, CRLF canonicalization. +3. **Validation** — generated test keys: expired, revoked, revoked UID, revoked encryption subkey, wrong UID, valid. ✅ + PGPainless and Bouncy Castle can disagree on a shortened expiry; the validator uses the earlier date. +4. **Encryptor + PGP/MIME** — round-trip decrypt test, structure test, CRLF canonicalization, attachments. ✅ + Verified live: the demo's mail to `test@thinhha.pro` is addressed to its cv25519 subkey `7CEE00AF05A08313`. 5. **Policy + delivery planner** — table-driven tests over key/no-key × REQUIRE/OPPORTUNISTIC/PLAINTEXT × To/Cc/Bcc; `EnvelopeMimeMessage`. 6. **SecureMailSender (BUILT_IN + RELAY)**, `SendReport`, sender keys, metrics, autoconfig switch. 7. **Demo + E2E** with GreenMail; manual check against a real WKD domain, decrypt in Thunderbird. - Demo app scaffolded early (sends plaintext test mails + reports WKD result) ✅; verified live: + Demo app ✅ — encrypts when a valid key is found, otherwise sends plaintext (logged). Verified live: `test@thinhha.pro` → found via advanced method, fpr `ADA6EFA0CC1630D602889820112495670FF1B4F0`. 8. **Hardening** — protected headers, actuator endpoint, docs. diff --git a/mailsender-demo/pom.xml b/mailsender-demo/pom.xml index 08d1b1f..cc230cb 100644 --- a/mailsender-demo/pom.xml +++ b/mailsender-demo/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-starter diff --git a/mailsender-demo/src/main/java/io/github/sneakybugmaster/mailsender/demo/TestMailRunner.java b/mailsender-demo/src/main/java/io/github/sneakybugmaster/mailsender/demo/TestMailRunner.java deleted file mode 100644 index 7d561d0..0000000 --- a/mailsender-demo/src/main/java/io/github/sneakybugmaster/mailsender/demo/TestMailRunner.java +++ /dev/null @@ -1,76 +0,0 @@ -package io.github.sneakybugmaster.mailsender.demo; - -import java.time.OffsetDateTime; -import java.util.HexFormat; -import java.util.stream.Collectors; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.boot.ApplicationArguments; -import org.springframework.boot.ApplicationRunner; -import org.springframework.mail.javamail.JavaMailSender; -import org.springframework.mail.javamail.MimeMessageHelper; -import org.springframework.stereotype.Component; - -import io.github.sneakybugmaster.mailsender.pgp.discovery.DiscoveryResult; -import io.github.sneakybugmaster.mailsender.pgp.discovery.KeyDiscovery; -import jakarta.mail.MessagingException; -import jakarta.mail.internet.MimeMessage; - -/** - * Looks up the recipient's key via WKD, then sends {@code demo.count} test mails describing what was found. - *

- * Encryption is not wired in yet (milestones 3–6), so the mails go out in plaintext. - */ -@Component -class TestMailRunner implements ApplicationRunner { - - private static final Logger log = LoggerFactory.getLogger(TestMailRunner.class); - - private final DemoProperties properties; - private final KeyDiscovery keyDiscovery; - private final JavaMailSender mailSender; - - TestMailRunner(DemoProperties properties, KeyDiscovery keyDiscovery, JavaMailSender mailSender) { - this.properties = properties; - this.keyDiscovery = keyDiscovery; - this.mailSender = mailSender; - } - - @Override - public void run(ApplicationArguments args) throws MessagingException { - DiscoveryResult discovery = keyDiscovery.discover(properties.to()); - String summary = describe(discovery); - log.info("Key discovery for {}: {}", properties.to(), summary); - - for (int i = 1; i <= properties.count(); i++) { - MimeMessage message = mailSender.createMimeMessage(); - MimeMessageHelper helper = new MimeMessageHelper(message, "UTF-8"); - if (properties.from() != null) { - helper.setFrom(properties.from()); - } - helper.setTo(properties.to()); - helper.setSubject("mailsender test %d/%d".formatted(i, properties.count())); - helper.setText(""" - Test mail %d of %d from the mailsender demo, sent %s. - - Key discovery for %s: %s - - This message was sent in plaintext: PGP/MIME encryption is not implemented yet. - """.formatted(i, properties.count(), OffsetDateTime.now(), properties.to(), summary)); - mailSender.send(message); - log.info("Sent test mail {}/{} to {}", i, properties.count(), properties.to()); - } - } - - private static String describe(DiscoveryResult result) { - return switch (result) { - case DiscoveryResult.Found found -> "found via " + found.source() + ", fingerprint(s) " - + found.certificates().stream() - .map(c -> HexFormat.of().withUpperCase().formatHex(c.getFingerprint())) - .collect(Collectors.joining(", ")); - case DiscoveryResult.NotFound ignored -> "no key published"; - case DiscoveryResult.Unreachable u -> "lookup failed (" + u.reason() + ")"; - }; - } -} diff --git a/mailsender-demo/src/main/java/io/github/sneakybugmaster/mailsender/demo/DemoApplication.java b/mailsender-demo/src/main/java/pro/thinhha/mailsender/demo/DemoApplication.java similarity index 88% rename from mailsender-demo/src/main/java/io/github/sneakybugmaster/mailsender/demo/DemoApplication.java rename to mailsender-demo/src/main/java/pro/thinhha/mailsender/demo/DemoApplication.java index 97b987f..b99b8e9 100644 --- a/mailsender-demo/src/main/java/io/github/sneakybugmaster/mailsender/demo/DemoApplication.java +++ b/mailsender-demo/src/main/java/pro/thinhha/mailsender/demo/DemoApplication.java @@ -1,4 +1,4 @@ -package io.github.sneakybugmaster.mailsender.demo; +package pro.thinhha.mailsender.demo; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; diff --git a/mailsender-demo/src/main/java/io/github/sneakybugmaster/mailsender/demo/DemoProperties.java b/mailsender-demo/src/main/java/pro/thinhha/mailsender/demo/DemoProperties.java similarity index 91% rename from mailsender-demo/src/main/java/io/github/sneakybugmaster/mailsender/demo/DemoProperties.java rename to mailsender-demo/src/main/java/pro/thinhha/mailsender/demo/DemoProperties.java index 7c57f92..9c0ea0c 100644 --- a/mailsender-demo/src/main/java/io/github/sneakybugmaster/mailsender/demo/DemoProperties.java +++ b/mailsender-demo/src/main/java/pro/thinhha/mailsender/demo/DemoProperties.java @@ -1,4 +1,4 @@ -package io.github.sneakybugmaster.mailsender.demo; +package pro.thinhha.mailsender.demo; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.context.properties.bind.DefaultValue; diff --git a/mailsender-demo/src/main/java/pro/thinhha/mailsender/demo/TestMailRunner.java b/mailsender-demo/src/main/java/pro/thinhha/mailsender/demo/TestMailRunner.java new file mode 100644 index 0000000..40703b6 --- /dev/null +++ b/mailsender-demo/src/main/java/pro/thinhha/mailsender/demo/TestMailRunner.java @@ -0,0 +1,123 @@ +package pro.thinhha.mailsender.demo; + +import java.io.IOException; +import java.time.OffsetDateTime; +import java.util.HexFormat; +import java.util.List; +import java.util.Optional; + +import org.bouncycastle.openpgp.api.OpenPGPCertificate; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.stereotype.Component; + +import jakarta.mail.MessagingException; +import jakarta.mail.internet.MimeMessage; +import pro.thinhha.mailsender.pgp.discovery.DiscoveryResult; +import pro.thinhha.mailsender.pgp.discovery.KeyDiscovery; +import pro.thinhha.mailsender.pgp.mime.PgpMimeEncryptor; +import pro.thinhha.mailsender.pgp.validation.CertificateValidator; +import pro.thinhha.mailsender.pgp.validation.ValidationResult; + +/** + * Looks up and validates the recipient's key, then sends {@code demo.count} test mails: PGP/MIME-encrypted when a + * valid key was found, otherwise in plaintext (logged as a downgrade). + *

+ * This calls the building blocks directly; the policy layer and {@code SecureMailSender} (milestones 5–6) will + * replace this wiring. + */ +@Component +class TestMailRunner implements ApplicationRunner { + + private static final Logger log = LoggerFactory.getLogger(TestMailRunner.class); + + private final DemoProperties properties; + private final KeyDiscovery keyDiscovery; + private final CertificateValidator validator; + private final PgpMimeEncryptor mimeEncryptor; + private final JavaMailSender mailSender; + + TestMailRunner(DemoProperties properties, KeyDiscovery keyDiscovery, CertificateValidator validator, + PgpMimeEncryptor mimeEncryptor, JavaMailSender mailSender) { + this.properties = properties; + this.keyDiscovery = keyDiscovery; + this.validator = validator; + this.mimeEncryptor = mimeEncryptor; + this.mailSender = mailSender; + } + + @Override + public void run(ApplicationArguments args) throws MessagingException, IOException { + String to = properties.to(); + DiscoveryResult discovery = keyDiscovery.discover(to); + Optional key = usableKey(discovery, to); + + for (int i = 1; i <= properties.count(); i++) { + MimeMessage message = compose(i, discovery, key.isPresent()); + if (key.isPresent()) { + message = mimeEncryptor.encrypt(message, List.of(key.get()), List.of(), mailSender::createMimeMessage); + log.info("Sending test mail {}/{} to {} ENCRYPTED to {}", i, properties.count(), to, + fingerprint(key.get())); + } else { + log.warn("Sending test mail {}/{} to {} in PLAINTEXT: no usable key", i, properties.count(), to); + } + mailSender.send(message); + } + } + + private Optional usableKey(DiscoveryResult discovery, String to) { + switch (discovery) { + case DiscoveryResult.Found found -> { + for (OpenPGPCertificate certificate : found.certificates()) { + ValidationResult result = validator.validate(certificate, to); + switch (result) { + case ValidationResult.Valid valid -> { + log.info("Key for {} found via {}: {}", to, found.source(), fingerprint(certificate)); + return Optional.of(valid.certificate()); + } + case ValidationResult.Rejected rejected -> log.warn("Key {} for {} rejected: {} ({})", + fingerprint(certificate), to, rejected.reason(), rejected.detail()); + } + } + } + case DiscoveryResult.NotFound ignored -> log.info("No key published for {}", to); + case DiscoveryResult.Unreachable unreachable -> + log.warn("Key lookup for {} failed: {}", to, unreachable.reason()); + } + return Optional.empty(); + } + + private MimeMessage compose(int index, DiscoveryResult discovery, boolean encrypted) throws MessagingException { + MimeMessage message = mailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(message, "UTF-8"); + if (properties.from() != null && !properties.from().isBlank()) { + helper.setFrom(properties.from()); + } + helper.setTo(properties.to()); + helper.setSubject("mailsender test %d/%d".formatted(index, properties.count())); + helper.setText(""" + Test mail %d of %d from the mailsender demo, sent %s. + + Key discovery for %s: %s + This message was sent %s. + """.formatted(index, properties.count(), OffsetDateTime.now(), properties.to(), + describe(discovery), encrypted ? "PGP/MIME-encrypted" : "in plaintext")); + return message; + } + + private static String describe(DiscoveryResult result) { + return switch (result) { + case DiscoveryResult.Found found -> "found via " + found.source(); + case DiscoveryResult.NotFound ignored -> "no key published"; + case DiscoveryResult.Unreachable u -> "lookup failed (" + u.reason() + ")"; + }; + } + + private static String fingerprint(OpenPGPCertificate certificate) { + return HexFormat.of().withUpperCase().formatHex(certificate.getFingerprint()); + } +} diff --git a/mailsender-demo/src/main/resources/application.yml b/mailsender-demo/src/main/resources/application.yml index ead8c61..afb0b32 100644 --- a/mailsender-demo/src/main/resources/application.yml +++ b/mailsender-demo/src/main/resources/application.yml @@ -20,4 +20,4 @@ demo: logging: level: - io.github.sneakybugmaster.mailsender: DEBUG + pro.thinhha.mailsender: DEBUG diff --git a/mailsender-demo/src/test/java/pro/thinhha/mailsender/demo/EncryptedTestMailTest.java b/mailsender-demo/src/test/java/pro/thinhha/mailsender/demo/EncryptedTestMailTest.java new file mode 100644 index 0000000..0b802d0 --- /dev/null +++ b/mailsender-demo/src/test/java/pro/thinhha/mailsender/demo/EncryptedTestMailTest.java @@ -0,0 +1,85 @@ +package pro.thinhha.mailsender.demo; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.bouncycastle.openpgp.api.OpenPGPKey; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.pgpainless.PGPainless; +import org.pgpainless.decryption_verification.ConsumerOptions; +import org.pgpainless.decryption_verification.DecryptionStream; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; + +import com.icegreen.greenmail.util.GreenMail; +import com.icegreen.greenmail.util.ServerSetupTest; + +import jakarta.mail.Multipart; +import jakarta.mail.internet.ContentType; +import jakarta.mail.internet.MimeBodyPart; +import jakarta.mail.internet.MimeMessage; + +/** The recipient has a (pinned) key, so the demo must deliver a decryptable PGP/MIME message. */ +@SpringBootTest(properties = { + "spring.mail.properties.mail.smtp.auth=false", + "spring.mail.properties.mail.smtp.starttls.enable=false", + "spring.mail.properties.mail.smtp.starttls.required=false", + "spring.mail.username=", + "demo.from=demo@mailsender.test", + "mail.pgp.wkd.enabled=false" +}) +class EncryptedTestMailTest { + + static final PGPainless API = PGPainless.getInstance(); + static final OpenPGPKey RECIPIENT = API.generateKey().modernKeyRing("Test Account "); + static GreenMail greenMail; + static Path keyFile; + + @BeforeAll + static void start() throws Exception { + keyFile = Files.createTempFile("recipient", ".asc"); + Files.writeString(keyFile, RECIPIENT.toCertificate().toAsciiArmoredString()); + greenMail = new GreenMail(ServerSetupTest.SMTP.dynamicPort()); + greenMail.start(); + } + + @AfterAll + static void stop() throws Exception { + greenMail.stop(); + Files.deleteIfExists(keyFile); + } + + @DynamicPropertySource + static void properties(DynamicPropertyRegistry registry) { + registry.add("spring.mail.host", () -> "127.0.0.1"); + registry.add("spring.mail.port", () -> greenMail.getSmtp().getPort()); + registry.add("mail.pgp.static-keys[test@thinhha.pro]", () -> "file:" + keyFile); + } + + @Test + void deliversDecryptablePgpMime() throws Exception { + MimeMessage[] received = greenMail.getReceivedMessages(); + assertThat(received).hasSize(1); + MimeMessage message = received[0]; + + assertThat(new ContentType(message.getContentType()).getBaseType()).isEqualTo("multipart/encrypted"); + assertThat(message.getSubject()).isEqualTo("mailsender test 1/1"); + Multipart parts = (Multipart) message.getContent(); + byte[] armored = parts.getBodyPart(1).getInputStream().readAllBytes(); + + ByteArrayOutputStream inner = new ByteArrayOutputStream(); + try (DecryptionStream in = API.processMessage().onInputStream(new ByteArrayInputStream(armored)) + .withOptions(ConsumerOptions.get(API).addDecryptionKey(RECIPIENT))) { + in.transferTo(inner); + } + MimeBodyPart entity = new MimeBodyPart(new ByteArrayInputStream(inner.toByteArray())); + assertThat(entity.getContent().toString()).contains("found via STATIC", "PGP/MIME-encrypted"); + } +} diff --git a/mailsender-demo/src/test/java/io/github/sneakybugmaster/mailsender/demo/TestMailRunnerTest.java b/mailsender-demo/src/test/java/pro/thinhha/mailsender/demo/PlaintextTestMailTest.java similarity index 95% rename from mailsender-demo/src/test/java/io/github/sneakybugmaster/mailsender/demo/TestMailRunnerTest.java rename to mailsender-demo/src/test/java/pro/thinhha/mailsender/demo/PlaintextTestMailTest.java index 78f1354..1d37b79 100644 --- a/mailsender-demo/src/test/java/io/github/sneakybugmaster/mailsender/demo/TestMailRunnerTest.java +++ b/mailsender-demo/src/test/java/pro/thinhha/mailsender/demo/PlaintextTestMailTest.java @@ -1,4 +1,4 @@ -package io.github.sneakybugmaster.mailsender.demo; +package pro.thinhha.mailsender.demo; import static org.assertj.core.api.Assertions.assertThat; @@ -25,7 +25,7 @@ import jakarta.mail.internet.MimeMessage; // keep the test offline: no real WKD lookup "mail.pgp.wkd.enabled=false" }) -class TestMailRunnerTest { +class PlaintextTestMailTest { // Started before the Spring context, because the runner sends while the context starts. static GreenMail greenMail; @@ -54,6 +54,6 @@ class TestMailRunnerTest { assertThat(received).hasSize(2); assertThat(received[0].getAllRecipients()).extracting(Object::toString).containsExactly("test@thinhha.pro"); assertThat(received[0].getSubject()).isEqualTo("mailsender test 1/2"); - assertThat(GreenMailUtil.getBody(received[0])).contains("no key published"); + assertThat(GreenMailUtil.getBody(received[0])).contains("no key published", "in plaintext"); } } diff --git a/mailsender-pgp-core/pom.xml b/mailsender-pgp-core/pom.xml index b2bbbbf..490de98 100644 --- a/mailsender-pgp-core/pom.xml +++ b/mailsender-pgp-core/pom.xml @@ -5,7 +5,7 @@ 4.0.0 - io.github.sneakybugmaster.mailsender + pro.thinhha.mailsender mailsender-parent 0.1.0-SNAPSHOT @@ -30,6 +30,11 @@ org.slf4j slf4j-api + + + org.eclipse.angus + angus-mail + org.junit.jupiter diff --git a/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/crypto/PgpEncryptor.java b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/crypto/PgpEncryptor.java new file mode 100644 index 0000000..0cff0f2 --- /dev/null +++ b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/crypto/PgpEncryptor.java @@ -0,0 +1,56 @@ +package pro.thinhha.mailsender.pgp.crypto; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.Collection; +import java.util.Objects; + +import org.bouncycastle.openpgp.PGPException; +import org.bouncycastle.openpgp.api.OpenPGPCertificate; +import org.pgpainless.PGPainless; +import org.pgpainless.encryption_signing.EncryptionOptions; +import org.pgpainless.encryption_signing.EncryptionStream; +import org.pgpainless.encryption_signing.ProducerOptions; + +/** Encrypts data to OpenPGP certificates and returns the ASCII-armored message. */ +public final class PgpEncryptor { + + private final PGPainless api; + + public PgpEncryptor() { + this(PGPainless.getInstance()); + } + + public PgpEncryptor(PGPainless api) { + this.api = Objects.requireNonNull(api, "api"); + } + + /** + * @param recipients certificates whose key IDs appear in the message + * @param hiddenRecipients certificates encrypted to with a wildcard key ID, so other recipients cannot tell + * they received it (use for Bcc) + * @return the ASCII-armored message, without armor headers + * @throws IllegalArgumentException if there is no recipient at all + */ + public byte[] encrypt(byte[] plaintext, Collection recipients, + Collection 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}