From 3bce057eab33c82ffb90dcac4ff1bb1ab5767668 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 03:27:40 +0000 Subject: [PATCH] Add encryption policy, split delivery and SecureMailSender - delivery: PolicyResolver (address > *@domain > default), RecipientKeyResolver (discovery + validation), DeliveryPlanner (REQUIRE refusal, SPLIT/FAIL, own copy per Bcc with hidden key IDs), MessageAssembler with sender keys for readable sent copies, EnvelopeMimeMessage for per-copy SMTP envelopes - sender: SecureMailSender with BUILT_IN and RELAY implementations and a per-recipient SendReport; relay passes the strictest policy as a header hint - autoconfigure: mode, default-policy, mixed-mode, recipients, sender-keys and relay properties; relay sender is not a bean so spring.mail stays intact - demo: sends through SecureMailSender Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G71TQ5jDETVwdUJxGAwaiT --- README.md | 34 +++- docs/PLAN.md | 17 +- .../mailsender/demo/TestMailRunner.java | 111 +++--------- .../src/main/resources/application.yml | 17 ++ .../demo/EncryptedTestMailTest.java | 2 +- .../demo/PlaintextTestMailTest.java | 2 +- .../mailsender/pgp/delivery/Delivery.java | 34 ++++ .../mailsender/pgp/delivery/DeliveryPlan.java | 12 ++ .../pgp/delivery/DeliveryPlanner.java | 140 +++++++++++++++ .../pgp/delivery/EncryptionPolicy.java | 11 ++ .../delivery/EncryptionPolicyException.java | 19 ++ .../pgp/delivery/EnvelopeMimeMessage.java | 35 ++++ .../pgp/delivery/MessageAssembler.java | 66 +++++++ .../mailsender/pgp/delivery/MixedMode.java | 9 + .../pgp/delivery/PolicyResolver.java | 44 +++++ .../pgp/delivery/RecipientDecision.java | 24 +++ .../mailsender/pgp/delivery/RecipientKey.java | 37 ++++ .../pgp/delivery/RecipientKeyResolver.java | 46 +++++ .../pgp/delivery/RecipientType.java | 6 + .../pgp/delivery/SenderKeyProvider.java | 20 +++ .../pgp/delivery/StaticSenderKeyProvider.java | 38 ++++ .../pgp/delivery/DeliveryPlannerTest.java | 167 +++++++++++++++++ .../pgp/delivery/MessageAssemblerTest.java | 93 ++++++++++ .../pgp/delivery/PolicyResolverTest.java | 35 ++++ .../pom.xml | 15 ++ .../PgpMailAutoConfiguration.java | 126 +++++++++++-- .../pgp/autoconfigure/PgpMailProperties.java | 49 ++++- .../pgp/sender/AbstractSecureMailSender.java | 41 +++++ .../pgp/sender/BuiltInSecureMailSender.java | 76 ++++++++ .../pgp/sender/RelaySecureMailSender.java | 71 ++++++++ .../pgp/sender/SecureMailSender.java | 25 +++ .../mailsender/pgp/sender/SendReport.java | 34 ++++ .../PgpMailAutoConfigurationTest.java | 50 +++++- .../SecureMailSenderIntegrationTest.java | 170 ++++++++++++++++++ 34 files changed, 1562 insertions(+), 114 deletions(-) create mode 100644 mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/Delivery.java create mode 100644 mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/DeliveryPlan.java create mode 100644 mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/DeliveryPlanner.java create mode 100644 mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/EncryptionPolicy.java create mode 100644 mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/EncryptionPolicyException.java create mode 100644 mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/EnvelopeMimeMessage.java create mode 100644 mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/MessageAssembler.java create mode 100644 mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/MixedMode.java create mode 100644 mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/PolicyResolver.java create mode 100644 mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/RecipientDecision.java create mode 100644 mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/RecipientKey.java create mode 100644 mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/RecipientKeyResolver.java create mode 100644 mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/RecipientType.java create mode 100644 mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/SenderKeyProvider.java create mode 100644 mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/StaticSenderKeyProvider.java create mode 100644 mailsender-pgp-core/src/test/java/pro/thinhha/mailsender/pgp/delivery/DeliveryPlannerTest.java create mode 100644 mailsender-pgp-core/src/test/java/pro/thinhha/mailsender/pgp/delivery/MessageAssemblerTest.java create mode 100644 mailsender-pgp-core/src/test/java/pro/thinhha/mailsender/pgp/delivery/PolicyResolverTest.java create mode 100644 mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/sender/AbstractSecureMailSender.java create mode 100644 mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/sender/BuiltInSecureMailSender.java create mode 100644 mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/sender/RelaySecureMailSender.java create mode 100644 mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/sender/SecureMailSender.java create mode 100644 mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/sender/SendReport.java create mode 100644 mailsender-pgp-spring-boot-autoconfigure/src/test/java/pro/thinhha/mailsender/pgp/sender/SecureMailSenderIntegrationTest.java diff --git a/README.md b/README.md index cecda2a..e240426 100644 --- a/README.md +++ b/README.md @@ -19,12 +19,38 @@ mvn verify | `mailsender-pgp-spring-boot-starter` | dependency bundle for applications | | `mailsender-demo` | sample app that sends test mails | +## Usage + +Add `pro.thinhha.mailsender:mailsender-pgp-spring-boot-starter`, configure `spring.mail.*` as usual, and send +through `SecureMailSender` instead of `JavaMailSender`: + +```java +SendReport report = secureMailSender.send(message); // MimeMessage or SimpleMailMessage +``` + +Each recipient's key is looked up via WKD (cached) and validated. The message is then sent PGP/MIME-encrypted to +recipients with a usable key and in plaintext to the rest (logged as a downgrade), or refused with +`EncryptionPolicyException` if a `REQUIRE` recipient has no key. `SendReport` lists the outcome per recipient. + +```yaml +mail: + pgp: + mode: BUILT_IN # or RELAY: hand plaintext to an encrypting gateway (mail.pgp.relay.*) + default-policy: OPPORTUNISTIC # REQUIRE | OPPORTUNISTIC | PLAINTEXT + mixed-mode: SPLIT # or FAIL + recipients: + "[*@partner.example]": REQUIRE + sender-keys: # your own PUBLIC key, so sent copies stay readable + "[noreply@example.com]": file:/etc/mail/noreply.pub.asc +``` + +Map keys that are addresses need the `[...]` brackets. See [docs/PLAN.md](docs/PLAN.md) for all options. + ## Demo app -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. +The demo app sends `DEMO_COUNT` test mails (default: 1 to `test@thinhha.pro`) through `SecureMailSender` and logs +the outcome per recipient: PGP/MIME-encrypted (RFC 3156) when a valid WKD key is found, otherwise plaintext. +Set `MAIL_PGP_MODE=RELAY` and `RELAY_HOST`/`RELAY_PORT`/`RELAY_USERNAME`/`RELAY_PASSWORD` to send via a gateway. ``` mvn -q install -DskipTests diff --git a/docs/PLAN.md b/docs/PLAN.md index 2219882..7e3a2cf 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -117,8 +117,9 @@ mail.pgp: ``` - `RelaySecureMailSender` uses its own `JavaMailSender` pointed at the relay (the app's normal `spring.mail.*` sender is untouched). -- REQUIRE/OPPORTUNISTIC cannot be enforced by the library in this mode; they are passed as a header hint - and enforcement is the gateway's responsibility. Documented and logged at startup. +- REQUIRE/OPPORTUNISTIC cannot be enforced by the library in this mode. With `policy-header` set, the strictest + policy among the recipients (`require` > `opportunistic` > `plaintext`) is passed as a header hint; enforcement is + the gateway's responsibility. Logged at startup. - Encrypt-to-self is the gateway's responsibility in this mode. ## Configuration (full shape) @@ -168,10 +169,14 @@ mail.pgp: 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. +5. **Policy + delivery planner** — `PolicyResolver`, `RecipientKeyResolver`, `DeliveryPlanner`, `MessageAssembler`, + `EnvelopeMimeMessage`; tests over key/no-key × REQUIRE/OPPORTUNISTIC/PLAINTEXT × To/Cc/Bcc. ✅ + All copies of one message share its Date and Message-ID; duplicates get one copy; nobody encryptable → one + plaintext copy (no split). +6. **SecureMailSender (BUILT_IN + RELAY)**, `SendReport`, sender keys, autoconfig switch. ✅ + GreenMail integration test checks per-mailbox (envelope) delivery, decryption by recipient and sender, REQUIRE + refusal with nothing sent, and relay mode with the policy header. Metrics moved to M8. 7. **Demo + E2E** with GreenMail; manual check against a real WKD domain, decrypt in Thunderbird. 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. +8. **Hardening** — Micrometer metrics, protected headers, actuator endpoint, docs. 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 index 40703b6..7c3c0b4 100644 --- a/mailsender-demo/src/main/java/pro/thinhha/mailsender/demo/TestMailRunner.java +++ b/mailsender-demo/src/main/java/pro/thinhha/mailsender/demo/TestMailRunner.java @@ -1,34 +1,22 @@ 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; +import pro.thinhha.mailsender.pgp.sender.SecureMailSender; +import pro.thinhha.mailsender.pgp.sender.SendReport; /** - * 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. + * Sends {@code demo.count} test mails through {@link SecureMailSender}, which encrypts per recipient according to + * {@code mail.pgp.*} (or hands them to the relay in RELAY mode), and logs what happened to each recipient. */ @Component class TestMailRunner implements ApplicationRunner { @@ -36,88 +24,35 @@ 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; + private final SecureMailSender mailSender; - TestMailRunner(DemoProperties properties, KeyDiscovery keyDiscovery, CertificateValidator validator, - PgpMimeEncryptor mimeEncryptor, JavaMailSender mailSender) { + TestMailRunner(DemoProperties properties, SecureMailSender 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); - + public void run(ApplicationArguments args) throws MessagingException { 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); + MimeMessage message = mailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(message, "UTF-8"); + if (properties.from() != null && !properties.from().isBlank()) { + helper.setFrom(properties.from()); } - mailSender.send(message); - } - } + 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. - 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()); - } - } + If you can read this in a PGP-capable client that shows it as encrypted, + WKD discovery and PGP/MIME encryption worked. + """.formatted(i, properties.count(), OffsetDateTime.now())); + + SendReport report = mailSender.send(message); + for (SendReport.RecipientOutcome outcome : report.recipients()) { + log.info("Test mail {}/{} to {}: {}{}", i, properties.count(), outcome.address(), outcome.outcome(), + outcome.detail() == null ? "" : " (" + outcome.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 afb0b32..d8e8734 100644 --- a/mailsender-demo/src/main/resources/application.yml +++ b/mailsender-demo/src/main/resources/application.yml @@ -13,6 +13,23 @@ spring: mail.smtp.connectiontimeout: 10000 mail.smtp.timeout: 10000 +mail: + pgp: + mode: ${MAIL_PGP_MODE:BUILT_IN} # BUILT_IN | RELAY + default-policy: ${MAIL_PGP_DEFAULT_POLICY:OPPORTUNISTIC} + mixed-mode: SPLIT + # recipients: + # "[*@partner.example]": REQUIRE + # Readable sent copies: the sender's own PUBLIC key per From address. + # sender-keys: + # "[noreply@example.com]": file:/etc/mail/noreply.pub.asc + relay: + host: ${RELAY_HOST:} + port: ${RELAY_PORT:587} + username: ${RELAY_USERNAME:} + password: ${RELAY_PASSWORD:} + policy-header: X-Encryption-Policy + demo: to: ${DEMO_TO:test@thinhha.pro} from: ${DEMO_FROM:${SMTP_USERNAME:}} 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 index 0b802d0..57d71e0 100644 --- a/mailsender-demo/src/test/java/pro/thinhha/mailsender/demo/EncryptedTestMailTest.java +++ b/mailsender-demo/src/test/java/pro/thinhha/mailsender/demo/EncryptedTestMailTest.java @@ -80,6 +80,6 @@ class EncryptedTestMailTest { in.transferTo(inner); } MimeBodyPart entity = new MimeBodyPart(new ByteArrayInputStream(inner.toByteArray())); - assertThat(entity.getContent().toString()).contains("found via STATIC", "PGP/MIME-encrypted"); + assertThat(entity.getContent().toString()).contains("Test mail 1 of 1"); } } diff --git a/mailsender-demo/src/test/java/pro/thinhha/mailsender/demo/PlaintextTestMailTest.java b/mailsender-demo/src/test/java/pro/thinhha/mailsender/demo/PlaintextTestMailTest.java index 1d37b79..ce2920a 100644 --- a/mailsender-demo/src/test/java/pro/thinhha/mailsender/demo/PlaintextTestMailTest.java +++ b/mailsender-demo/src/test/java/pro/thinhha/mailsender/demo/PlaintextTestMailTest.java @@ -54,6 +54,6 @@ class PlaintextTestMailTest { 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", "in plaintext"); + assertThat(GreenMailUtil.getBody(received[0])).contains("Test mail 1 of 2"); } } diff --git a/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/Delivery.java b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/Delivery.java new file mode 100644 index 0000000..5d2b685 --- /dev/null +++ b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/Delivery.java @@ -0,0 +1,34 @@ +package pro.thinhha.mailsender.pgp.delivery; + +import java.util.List; + +import org.bouncycastle.openpgp.api.OpenPGPCertificate; + +import jakarta.mail.internet.InternetAddress; + +/** + * One copy of the message to send. + * + * @param envelope SMTP recipients of this copy (headers still show the original To/Cc) + * @param recipientKeys certificates to encrypt to; empty for a plaintext copy + * @param hiddenRecipients true to encrypt without revealing key IDs (Bcc copies) + */ +public record Delivery(List envelope, List recipientKeys, + boolean hiddenRecipients) { + + public Delivery { + envelope = List.copyOf(envelope); + recipientKeys = List.copyOf(recipientKeys); + if (envelope.isEmpty()) { + throw new IllegalArgumentException("A delivery needs at least one envelope recipient"); + } + } + + static Delivery plaintext(List envelope) { + return new Delivery(envelope, List.of(), false); + } + + public boolean encrypted() { + return !recipientKeys.isEmpty(); + } +} diff --git a/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/DeliveryPlan.java b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/DeliveryPlan.java new file mode 100644 index 0000000..8c13a6e --- /dev/null +++ b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/DeliveryPlan.java @@ -0,0 +1,12 @@ +package pro.thinhha.mailsender.pgp.delivery; + +import java.util.List; + +/** The copies to send and the per-recipient decisions behind them. */ +public record DeliveryPlan(List deliveries, List decisions) { + + public DeliveryPlan { + deliveries = List.copyOf(deliveries); + decisions = List.copyOf(decisions); + } +} diff --git a/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/DeliveryPlanner.java b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/DeliveryPlanner.java new file mode 100644 index 0000000..a04018e --- /dev/null +++ b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/DeliveryPlanner.java @@ -0,0 +1,140 @@ +package pro.thinhha.mailsender.pgp.delivery; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; + +import org.bouncycastle.openpgp.api.OpenPGPCertificate; + +import jakarta.mail.internet.InternetAddress; + +/** + * Turns the recipients of one message into deliveries: + *

+ * An address listed more than once receives one copy, under its first header. + */ +public final class DeliveryPlanner { + + private final PolicyResolver policies; + private final RecipientKeyResolver keys; + private final MixedMode mixedMode; + + public DeliveryPlanner(PolicyResolver policies, RecipientKeyResolver keys, MixedMode mixedMode) { + this.policies = Objects.requireNonNull(policies, "policies"); + this.keys = Objects.requireNonNull(keys, "keys"); + this.mixedMode = Objects.requireNonNull(mixedMode, "mixedMode"); + } + + public DeliveryPlan plan(List to, List cc, List bcc) { + Map recipients = new LinkedHashMap<>(); + add(recipients, to, RecipientType.TO); + add(recipients, cc, RecipientType.CC); + add(recipients, bcc, RecipientType.BCC); + if (recipients.isEmpty()) { + throw new IllegalArgumentException("Message has no recipients"); + } + + List missingRequired = new ArrayList<>(); + for (Recipient recipient : recipients.values()) { + recipient.policy = policies.resolve(recipient.email()); + recipient.key = recipient.policy == EncryptionPolicy.PLAINTEXT + ? new RecipientKey.NotLookedUp() + : keys.resolve(recipient.email()); + if (recipient.policy == EncryptionPolicy.REQUIRE && !recipient.hasKey()) { + missingRequired.add(recipient.email()); + } + } + if (!missingRequired.isEmpty()) { + throw new EncryptionPolicyException("Encryption required but no usable key", missingRequired); + } + + List all = List.copyOf(recipients.values()); + boolean anyEncrypted = all.stream().anyMatch(Recipient::hasKey); + List plaintext = all.stream().filter(r -> !r.hasKey()).map(Recipient::email).toList(); + if (!anyEncrypted) { + return new DeliveryPlan( + List.of(Delivery.plaintext(all.stream().map(Recipient::address).toList())), + decisions(all)); + } + if (mixedMode == MixedMode.FAIL && !plaintext.isEmpty()) { + throw new EncryptionPolicyException("Mixed encrypted/plaintext recipients not allowed", plaintext); + } + return new DeliveryPlan(split(all), decisions(all)); + } + + private static List split(List all) { + List deliveries = new ArrayList<>(); + List visible = all.stream().filter(r -> r.type != RecipientType.BCC).toList(); + + List visibleEncrypted = visible.stream().filter(Recipient::hasKey).toList(); + if (!visibleEncrypted.isEmpty()) { + deliveries.add(new Delivery(addresses(visibleEncrypted), certificates(visibleEncrypted), false)); + } + List visiblePlain = visible.stream().filter(r -> !r.hasKey()).toList(); + if (!visiblePlain.isEmpty()) { + deliveries.add(Delivery.plaintext(addresses(visiblePlain))); + } + for (Recipient bcc : all) { + if (bcc.type == RecipientType.BCC) { + deliveries.add(bcc.hasKey() + ? new Delivery(List.of(bcc.address), certificates(List.of(bcc)), true) + : Delivery.plaintext(List.of(bcc.address))); + } + } + return deliveries; + } + + private static List decisions(List all) { + return all.stream() + .map(r -> new RecipientDecision(r.email(), r.type, r.policy, r.key, r.hasKey())) + .toList(); + } + + private static List addresses(List recipients) { + return recipients.stream().map(Recipient::address).toList(); + } + + private static List certificates(List recipients) { + return recipients.stream().map(r -> ((RecipientKey.Usable) r.key).certificate()).toList(); + } + + private static void add(Map recipients, List addresses, RecipientType type) { + for (InternetAddress address : addresses) { + recipients.putIfAbsent(address.getAddress().toLowerCase(Locale.ROOT), new Recipient(address, type)); + } + } + + private static final class Recipient { + final InternetAddress address; + final RecipientType type; + EncryptionPolicy policy; + RecipientKey key; + + Recipient(InternetAddress address, RecipientType type) { + this.address = address; + this.type = type; + } + + InternetAddress address() { + return address; + } + + String email() { + return address.getAddress(); + } + + /** Will this recipient get an encrypted copy? PLAINTEXT recipients never do. */ + boolean hasKey() { + return key instanceof RecipientKey.Usable; + } + } +} diff --git a/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/EncryptionPolicy.java b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/EncryptionPolicy.java new file mode 100644 index 0000000..276340d --- /dev/null +++ b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/EncryptionPolicy.java @@ -0,0 +1,11 @@ +package pro.thinhha.mailsender.pgp.delivery; + +/** What to do for a recipient, depending on whether a usable key exists. */ +public enum EncryptionPolicy { + /** Encrypt; refuse to send if no usable key exists. */ + REQUIRE, + /** Encrypt when a usable key exists, otherwise send in plaintext (logged as a downgrade). */ + OPPORTUNISTIC, + /** Always send in plaintext; no key lookup. */ + PLAINTEXT +} diff --git a/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/EncryptionPolicyException.java b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/EncryptionPolicyException.java new file mode 100644 index 0000000..5376ae8 --- /dev/null +++ b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/EncryptionPolicyException.java @@ -0,0 +1,19 @@ +package pro.thinhha.mailsender.pgp.delivery; + +import java.util.List; + +/** The message cannot be sent without violating the encryption policy. Nothing was sent. */ +public class EncryptionPolicyException extends RuntimeException { + + private final List addresses; + + public EncryptionPolicyException(String message, List addresses) { + super(message + ": " + String.join(", ", addresses)); + this.addresses = List.copyOf(addresses); + } + + /** The recipients that caused the refusal. */ + public List addresses() { + return addresses; + } +} diff --git a/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/EnvelopeMimeMessage.java b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/EnvelopeMimeMessage.java new file mode 100644 index 0000000..be2d02f --- /dev/null +++ b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/EnvelopeMimeMessage.java @@ -0,0 +1,35 @@ +package pro.thinhha.mailsender.pgp.delivery; + +import java.util.List; + +import jakarta.mail.Address; +import jakarta.mail.MessagingException; +import jakarta.mail.internet.MimeMessage; + +/** + * A copy of a message whose SMTP envelope is limited to some recipients while the To/Cc headers stay intact. + * {@code JavaMailSenderImpl} (like {@code Transport.send}) uses {@link #getAllRecipients()} as the envelope. + */ +public final class EnvelopeMimeMessage extends MimeMessage { + + private final Address[] envelope; + + public EnvelopeMimeMessage(MimeMessage source, List envelope) throws MessagingException { + super(source); + this.envelope = envelope.toArray(Address[]::new); + removeHeader("Bcc"); + } + + @Override + public Address[] getAllRecipients() { + return envelope.clone(); + } + + /** Keeps the source's Message-ID; the default would generate a new one on every save. */ + @Override + protected void updateMessageID() throws MessagingException { + if (getHeader("Message-ID") == null) { + super.updateMessageID(); + } + } +} diff --git a/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/MessageAssembler.java b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/MessageAssembler.java new file mode 100644 index 0000000..175e06e --- /dev/null +++ b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/MessageAssembler.java @@ -0,0 +1,66 @@ +package pro.thinhha.mailsender.pgp.delivery; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Objects; +import java.util.function.Supplier; + +import org.bouncycastle.openpgp.api.OpenPGPCertificate; + +import jakarta.mail.Address; +import jakarta.mail.MessagingException; +import jakarta.mail.internet.InternetAddress; +import jakarta.mail.internet.MimeMessage; +import pro.thinhha.mailsender.pgp.mime.PgpMimeEncryptor; + +/** Builds the concrete messages for a {@link DeliveryPlan}. */ +public final class MessageAssembler { + + private final PgpMimeEncryptor mimeEncryptor; + private final SenderKeyProvider senderKeys; + + public MessageAssembler(PgpMimeEncryptor mimeEncryptor, SenderKeyProvider senderKeys) { + this.mimeEncryptor = Objects.requireNonNull(mimeEncryptor, "mimeEncryptor"); + this.senderKeys = Objects.requireNonNull(senderKeys, "senderKeys"); + } + + /** + * @param newMessage creates empty messages bound to the sending session, e.g. + * {@code JavaMailSender::createMimeMessage} + */ + public List assemble(MimeMessage original, DeliveryPlan plan, Supplier newMessage) + throws MessagingException, IOException { + List selfKeys = senderKey(original); + // All copies are the same message to their readers: same Date and Message-ID. + if (original.getSentDate() == null) { + original.setSentDate(new Date()); + } + original.saveChanges(); + String messageId = original.getMessageID(); + + List messages = new ArrayList<>(); + for (Delivery delivery : plan.deliveries()) { + MimeMessage message = original; + if (delivery.encrypted()) { + List visible = new ArrayList<>(selfKeys); + List hidden = new ArrayList<>(); + (delivery.hiddenRecipients() ? hidden : visible).addAll(delivery.recipientKeys()); + message = mimeEncryptor.encrypt(original, visible, hidden, newMessage); + } + EnvelopeMimeMessage copy = new EnvelopeMimeMessage(message, delivery.envelope()); + copy.setHeader("Message-ID", messageId); + messages.add(copy); + } + return messages; + } + + private List senderKey(MimeMessage original) throws MessagingException { + Address[] from = original.getFrom(); + if (from == null || from.length == 0 || !(from[0] instanceof InternetAddress sender)) { + return List.of(); + } + return senderKeys.keyFor(sender.getAddress()).map(List::of).orElse(List.of()); + } +} diff --git a/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/MixedMode.java b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/MixedMode.java new file mode 100644 index 0000000..f23ae5b --- /dev/null +++ b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/MixedMode.java @@ -0,0 +1,9 @@ +package pro.thinhha.mailsender.pgp.delivery; + +/** What to do when some recipients of one message will get it encrypted and others in plaintext. */ +public enum MixedMode { + /** Send an encrypted copy to recipients with keys and a plaintext copy to the rest. */ + SPLIT, + /** Refuse to send. */ + FAIL +} diff --git a/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/PolicyResolver.java b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/PolicyResolver.java new file mode 100644 index 0000000..99cde49 --- /dev/null +++ b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/PolicyResolver.java @@ -0,0 +1,44 @@ +package pro.thinhha.mailsender.pgp.delivery; + +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; + +/** + * Resolves the {@link EncryptionPolicy} for an address: an exact address rule wins over a {@code *@domain} rule, + * which wins over the default. Matching ignores case. + */ +public final class PolicyResolver { + + private final EncryptionPolicy defaultPolicy; + private final Map rules; + + public PolicyResolver(EncryptionPolicy defaultPolicy, Map rules) { + this.defaultPolicy = Objects.requireNonNull(defaultPolicy, "defaultPolicy"); + Map normalized = new HashMap<>(); + rules.forEach((pattern, policy) -> { + String key = normalize(pattern); + if (!key.contains("@")) { + throw new IllegalArgumentException( + "Policy rule must be an address or *@domain, got: " + pattern); + } + normalized.put(key, Objects.requireNonNull(policy, pattern)); + }); + this.rules = Map.copyOf(normalized); + } + + public EncryptionPolicy resolve(String email) { + String address = normalize(email); + EncryptionPolicy exact = rules.get(address); + if (exact != null) { + return exact; + } + String domain = address.substring(address.lastIndexOf('@') + 1); + return rules.getOrDefault("*@" + domain, defaultPolicy); + } + + private static String normalize(String value) { + return value.strip().toLowerCase(Locale.ROOT); + } +} diff --git a/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/RecipientDecision.java b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/RecipientDecision.java new file mode 100644 index 0000000..a89a42c --- /dev/null +++ b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/RecipientDecision.java @@ -0,0 +1,24 @@ +package pro.thinhha.mailsender.pgp.delivery; + +import java.util.Objects; + +/** + * How one recipient will receive the message. + * + * @param encrypted whether the copy this recipient receives is encrypted + */ +public record RecipientDecision(String address, RecipientType type, EncryptionPolicy policy, RecipientKey key, + boolean encrypted) { + + public RecipientDecision { + Objects.requireNonNull(address, "address"); + Objects.requireNonNull(type, "type"); + Objects.requireNonNull(policy, "policy"); + Objects.requireNonNull(key, "key"); + } + + /** An OPPORTUNISTIC recipient that gets plaintext because no usable key exists. */ + public boolean isDowngrade() { + return !encrypted && policy == EncryptionPolicy.OPPORTUNISTIC; + } +} diff --git a/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/RecipientKey.java b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/RecipientKey.java new file mode 100644 index 0000000..0cbc583 --- /dev/null +++ b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/RecipientKey.java @@ -0,0 +1,37 @@ +package pro.thinhha.mailsender.pgp.delivery; + +import java.util.Objects; + +import org.bouncycastle.openpgp.api.OpenPGPCertificate; + +import pro.thinhha.mailsender.pgp.discovery.DiscoverySource; + +/** Whether a recipient has a usable (discovered and validated) key. */ +public sealed interface RecipientKey { + + record Usable(OpenPGPCertificate certificate, DiscoverySource source) implements RecipientKey { + public Usable { + Objects.requireNonNull(certificate, "certificate"); + Objects.requireNonNull(source, "source"); + } + } + + record Missing(Reason reason, String detail) implements RecipientKey { + public Missing { + Objects.requireNonNull(reason, "reason"); + } + } + + /** No lookup was made because the recipient's policy is {@link EncryptionPolicy#PLAINTEXT}. */ + record NotLookedUp() implements RecipientKey { + } + + enum Reason { + /** No key published. */ + NOT_FOUND, + /** The lookup failed (timeout, server error, malformed response). */ + UNREACHABLE, + /** Keys were found but none passed validation. */ + REJECTED + } +} diff --git a/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/RecipientKeyResolver.java b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/RecipientKeyResolver.java new file mode 100644 index 0000000..64c2d2f --- /dev/null +++ b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/RecipientKeyResolver.java @@ -0,0 +1,46 @@ +package pro.thinhha.mailsender.pgp.delivery; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import org.bouncycastle.openpgp.api.OpenPGPCertificate; + +import pro.thinhha.mailsender.pgp.discovery.DiscoveryResult; +import pro.thinhha.mailsender.pgp.discovery.KeyDiscovery; +import pro.thinhha.mailsender.pgp.validation.CertificateValidator; +import pro.thinhha.mailsender.pgp.validation.ValidationResult; + +/** Discovers and validates a recipient's key; the first certificate that passes validation is used. */ +public final class RecipientKeyResolver { + + private final KeyDiscovery discovery; + private final CertificateValidator validator; + + public RecipientKeyResolver(KeyDiscovery discovery, CertificateValidator validator) { + this.discovery = Objects.requireNonNull(discovery, "discovery"); + this.validator = Objects.requireNonNull(validator, "validator"); + } + + public RecipientKey resolve(String email) { + return switch (discovery.discover(email)) { + case DiscoveryResult.Found found -> firstValid(found, email); + case DiscoveryResult.NotFound ignored -> new RecipientKey.Missing(RecipientKey.Reason.NOT_FOUND, null); + case DiscoveryResult.Unreachable u -> new RecipientKey.Missing(RecipientKey.Reason.UNREACHABLE, u.reason()); + }; + } + + private RecipientKey firstValid(DiscoveryResult.Found found, String email) { + List rejections = new ArrayList<>(); + for (OpenPGPCertificate certificate : found.certificates()) { + switch (validator.validate(certificate, email)) { + case ValidationResult.Valid valid -> { + return new RecipientKey.Usable(valid.certificate(), found.source()); + } + case ValidationResult.Rejected rejected -> + rejections.add(rejected.reason() + " (" + rejected.detail() + ")"); + } + } + return new RecipientKey.Missing(RecipientKey.Reason.REJECTED, String.join("; ", rejections)); + } +} diff --git a/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/RecipientType.java b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/RecipientType.java new file mode 100644 index 0000000..6b6b3c3 --- /dev/null +++ b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/RecipientType.java @@ -0,0 +1,6 @@ +package pro.thinhha.mailsender.pgp.delivery; + +/** Header a recipient was addressed in. */ +public enum RecipientType { + TO, CC, BCC +} diff --git a/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/SenderKeyProvider.java b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/SenderKeyProvider.java new file mode 100644 index 0000000..0d46af7 --- /dev/null +++ b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/SenderKeyProvider.java @@ -0,0 +1,20 @@ +package pro.thinhha.mailsender.pgp.delivery; + +import java.util.Optional; + +import org.bouncycastle.openpgp.api.OpenPGPCertificate; + +/** + * The sender's own public key, added as an extra recipient so the sender can read the sent copy. Applications that + * need readable sent copies must provide a key for each From address; without one the copy is unreadable for the + * sender. + */ +@FunctionalInterface +public interface SenderKeyProvider { + + Optional keyFor(String fromAddress); + + static SenderKeyProvider none() { + return from -> Optional.empty(); + } +} diff --git a/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/StaticSenderKeyProvider.java b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/StaticSenderKeyProvider.java new file mode 100644 index 0000000..fa6ba25 --- /dev/null +++ b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/delivery/StaticSenderKeyProvider.java @@ -0,0 +1,38 @@ +package pro.thinhha.mailsender.pgp.delivery; + +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; + +import org.bouncycastle.openpgp.api.OpenPGPCertificate; + +import pro.thinhha.mailsender.pgp.validation.CertificateValidator; +import pro.thinhha.mailsender.pgp.validation.ValidationResult; + +/** Sender keys from configuration, validated when constructed so a bad key fails at startup, not on send. */ +public final class StaticSenderKeyProvider implements SenderKeyProvider { + + private final Map keys; + + public StaticSenderKeyProvider(Map keys, CertificateValidator validator) { + Map normalized = new HashMap<>(); + keys.forEach((address, certificate) -> { + if (validator.validate(certificate, address) instanceof ValidationResult.Rejected rejected) { + throw new IllegalArgumentException("Sender key for " + address + " is not usable: " + + rejected.reason() + " (" + rejected.detail() + ")"); + } + normalized.put(address.strip().toLowerCase(Locale.ROOT), certificate); + }); + this.keys = Map.copyOf(normalized); + } + + @Override + public Optional keyFor(String fromAddress) { + return Optional.ofNullable(keys.get(fromAddress.strip().toLowerCase(Locale.ROOT))); + } + + public boolean isEmpty() { + return keys.isEmpty(); + } +} diff --git a/mailsender-pgp-core/src/test/java/pro/thinhha/mailsender/pgp/delivery/DeliveryPlannerTest.java b/mailsender-pgp-core/src/test/java/pro/thinhha/mailsender/pgp/delivery/DeliveryPlannerTest.java new file mode 100644 index 0000000..d7adc0e --- /dev/null +++ b/mailsender-pgp-core/src/test/java/pro/thinhha/mailsender/pgp/delivery/DeliveryPlannerTest.java @@ -0,0 +1,167 @@ +package pro.thinhha.mailsender.pgp.delivery; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.bouncycastle.openpgp.api.OpenPGPCertificate; +import org.junit.jupiter.api.Test; +import org.pgpainless.PGPainless; + +import jakarta.mail.internet.InternetAddress; +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.validation.CertificateValidator; + +class DeliveryPlannerTest { + + private static final OpenPGPCertificate ALICE = cert("alice@example.org"); + private static final OpenPGPCertificate BOB = cert("bob@example.org"); + private static final OpenPGPCertificate DAVE = cert("dave@example.org"); + + private final Map published = new HashMap<>(Map.of( + "alice@example.org", found(ALICE), + "bob@example.org", found(BOB), + "dave@example.org", found(DAVE))); + private final Map rules = new HashMap<>(); + private int lookups; + + @Test + void everyoneWithKeysGetsOneEncryptedCopy() { + DeliveryPlan plan = planner(MixedMode.SPLIT).plan(list("alice@example.org"), list("bob@example.org"), list()); + + assertThat(plan.deliveries()).singleElement().satisfies(d -> { + assertThat(d.encrypted()).isTrue(); + assertThat(d.hiddenRecipients()).isFalse(); + assertThat(d.envelope()).extracting(InternetAddress::getAddress) + .containsExactly("alice@example.org", "bob@example.org"); + assertThat(d.recipientKeys()).containsExactly(ALICE, BOB); + }); + assertThat(plan.decisions()).allMatch(RecipientDecision::encrypted); + } + + @Test + void nobodyWithKeysGetsOnePlaintextCopyIncludingBcc() { + DeliveryPlan plan = planner(MixedMode.FAIL) + .plan(list("carol@example.org"), list(), list("erin@example.org")); + + assertThat(plan.deliveries()).singleElement().satisfies(d -> { + assertThat(d.encrypted()).isFalse(); + assertThat(d.envelope()).extracting(InternetAddress::getAddress) + .containsExactly("carol@example.org", "erin@example.org"); + }); + assertThat(plan.decisions()).allMatch(RecipientDecision::isDowngrade); + } + + @Test + void mixedRecipientsAreSplit() { + DeliveryPlan plan = planner(MixedMode.SPLIT).plan( + list("alice@example.org", "carol@example.org"), list(), list("dave@example.org", "erin@example.org")); + + assertThat(plan.deliveries()).hasSize(4); + Delivery encrypted = plan.deliveries().get(0); + assertThat(encrypted.encrypted()).isTrue(); + assertThat(encrypted.envelope()).extracting(InternetAddress::getAddress).containsExactly("alice@example.org"); + + Delivery plain = plan.deliveries().get(1); + assertThat(plain.encrypted()).isFalse(); + assertThat(plain.envelope()).extracting(InternetAddress::getAddress).containsExactly("carol@example.org"); + + Delivery bccWithKey = plan.deliveries().get(2); + assertThat(bccWithKey.encrypted()).isTrue(); + assertThat(bccWithKey.hiddenRecipients()).isTrue(); + assertThat(bccWithKey.recipientKeys()).containsExactly(DAVE); + assertThat(bccWithKey.envelope()).extracting(InternetAddress::getAddress).containsExactly("dave@example.org"); + + Delivery bccWithoutKey = plan.deliveries().get(3); + assertThat(bccWithoutKey.encrypted()).isFalse(); + assertThat(bccWithoutKey.envelope()).extracting(InternetAddress::getAddress) + .containsExactly("erin@example.org"); + } + + @Test + void mixedRecipientsAreRefusedInFailMode() { + assertThatThrownBy(() -> planner(MixedMode.FAIL).plan( + list("alice@example.org", "carol@example.org"), list(), list())) + .isInstanceOfSatisfying(EncryptionPolicyException.class, + e -> assertThat(e.addresses()).containsExactly("carol@example.org")); + } + + @Test + void requiredRecipientWithoutKeyRefusesWholeMessage() { + rules.put("*@example.org", EncryptionPolicy.REQUIRE); + + assertThatThrownBy(() -> planner(MixedMode.SPLIT).plan( + list("alice@example.org", "carol@example.org"), list(), list())) + .isInstanceOfSatisfying(EncryptionPolicyException.class, + e -> assertThat(e.addresses()).containsExactly("carol@example.org")); + } + + @Test + void plaintextPolicySkipsLookupAndGetsPlaintextCopy() { + rules.put("bob@example.org", EncryptionPolicy.PLAINTEXT); + + DeliveryPlan plan = planner(MixedMode.SPLIT).plan(list("alice@example.org", "bob@example.org"), list(), list()); + + assertThat(lookups).isEqualTo(1); + assertThat(plan.deliveries()).hasSize(2); + RecipientDecision bob = plan.decisions().get(1); + assertThat(bob.key()).isInstanceOf(RecipientKey.NotLookedUp.class); + assertThat(bob.encrypted()).isFalse(); + assertThat(bob.isDowngrade()).isFalse(); + } + + @Test + void invalidKeyCountsAsMissing() { + published.put("carol@example.org", found(cert("mallory@example.org"))); + + DeliveryPlan plan = planner(MixedMode.SPLIT).plan(list("carol@example.org"), list(), list()); + + assertThat(plan.decisions()).singleElement().satisfies(d -> { + assertThat(d.encrypted()).isFalse(); + assertThat(d.key()).isInstanceOfSatisfying(RecipientKey.Missing.class, + m -> assertThat(m.reason()).isEqualTo(RecipientKey.Reason.REJECTED)); + }); + } + + @Test + void duplicateAddressGetsOneCopy() { + DeliveryPlan plan = planner(MixedMode.SPLIT) + .plan(list("alice@example.org"), list("Alice@Example.org"), list("alice@example.org")); + + assertThat(plan.decisions()).singleElement().extracting(RecipientDecision::type).isEqualTo(RecipientType.TO); + assertThat(plan.deliveries()).singleElement() + .satisfies(d -> assertThat(d.envelope()).hasSize(1)); + } + + private DeliveryPlanner planner(MixedMode mode) { + KeyDiscovery discovery = email -> { + lookups++; + return published.getOrDefault(email.toLowerCase(), DiscoveryResult.notFound()); + }; + return new DeliveryPlanner(new PolicyResolver(EncryptionPolicy.OPPORTUNISTIC, rules), + new RecipientKeyResolver(discovery, new CertificateValidator()), mode); + } + + private static List list(String... addresses) { + return java.util.Arrays.stream(addresses).map(a -> { + try { + return new InternetAddress(a); + } catch (jakarta.mail.internet.AddressException e) { + throw new IllegalArgumentException(e); + } + }).toList(); + } + + private static DiscoveryResult found(OpenPGPCertificate certificate) { + return new DiscoveryResult.Found(List.of(certificate), DiscoverySource.WKD_ADVANCED); + } + + private static OpenPGPCertificate cert(String email) { + return PGPainless.getInstance().generateKey().modernKeyRing("<" + email + ">").toCertificate(); + } +} diff --git a/mailsender-pgp-core/src/test/java/pro/thinhha/mailsender/pgp/delivery/MessageAssemblerTest.java b/mailsender-pgp-core/src/test/java/pro/thinhha/mailsender/pgp/delivery/MessageAssemblerTest.java new file mode 100644 index 0000000..22331fd --- /dev/null +++ b/mailsender-pgp-core/src/test/java/pro/thinhha/mailsender/pgp/delivery/MessageAssemblerTest.java @@ -0,0 +1,93 @@ +package pro.thinhha.mailsender.pgp.delivery; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Properties; + +import org.bouncycastle.openpgp.api.OpenPGPKey; +import org.junit.jupiter.api.Test; +import org.pgpainless.PGPainless; + +import jakarta.mail.Message; +import jakarta.mail.Multipart; +import jakarta.mail.Session; +import jakarta.mail.internet.InternetAddress; +import jakarta.mail.internet.MimeMessage; +import pro.thinhha.mailsender.pgp.crypto.PgpEncryptor; +import pro.thinhha.mailsender.pgp.crypto.PgpEncryptorTest; +import pro.thinhha.mailsender.pgp.mime.PgpMimeEncryptor; +import pro.thinhha.mailsender.pgp.validation.CertificateValidator; + +class MessageAssemblerTest { + + private static final PGPainless API = PGPainless.getInstance(); + private static final OpenPGPKey ALICE = API.generateKey().modernKeyRing(""); + private static final OpenPGPKey DAVE = API.generateKey().modernKeyRing(""); + private static final OpenPGPKey SENDER = API.generateKey().modernKeyRing(""); + private static final Session SESSION = Session.getInstance(new Properties()); + + @Test + void buildsEnvelopeLimitedCopiesThatShareHeaders() throws Exception { + MimeMessage original = new MimeMessage(SESSION); + original.setFrom("sender@example.com"); + original.setRecipients(Message.RecipientType.TO, "alice@example.org, carol@example.org"); + original.setRecipients(Message.RecipientType.BCC, "dave@example.org"); + original.setSubject("Split"); + original.setText("secret"); + + DeliveryPlan plan = new DeliveryPlan(List.of( + new Delivery(List.of(new InternetAddress("alice@example.org")), List.of(ALICE.toCertificate()), false), + Delivery.plaintext(List.of(new InternetAddress("carol@example.org"))), + new Delivery(List.of(new InternetAddress("dave@example.org")), List.of(DAVE.toCertificate()), true)), + List.of()); + SenderKeyProvider senderKeys = new StaticSenderKeyProvider( + Map.of("Sender@Example.com", SENDER.toCertificate()), new CertificateValidator()); + + List messages = new MessageAssembler(new PgpMimeEncryptor(new PgpEncryptor()), senderKeys) + .assemble(original, plan, () -> new MimeMessage(SESSION)); + + assertThat(messages).hasSize(3); + assertThat(messages).extracting(MimeMessage::getMessageID).doesNotContainNull().containsOnly( + messages.get(0).getMessageID()); + for (MimeMessage message : messages) { + assertThat(message.getHeader("Bcc")).isNull(); + assertThat(message.getRecipients(Message.RecipientType.TO)).hasSize(2); + assertThat(message.getSubject()).isEqualTo("Split"); + } + + MimeMessage toAlice = messages.get(0); + assertThat(toAlice.getAllRecipients()).extracting(Object::toString).containsExactly("alice@example.org"); + assertThat(toAlice.isMimeType("multipart/encrypted")).isTrue(); + byte[] armored = encryptedPart(toAlice); + assertThat(new String(PgpEncryptorTest.decrypt(armored, ALICE))).contains("secret"); + assertThat(new String(PgpEncryptorTest.decrypt(armored, SENDER))).as("readable sent copy").contains("secret"); + + MimeMessage toCarol = messages.get(1); + assertThat(toCarol.getAllRecipients()).extracting(Object::toString).containsExactly("carol@example.org"); + assertThat(toCarol.getContent()).isEqualTo("secret"); + + MimeMessage toDave = messages.get(2); + assertThat(toDave.getAllRecipients()).extracting(Object::toString).containsExactly("dave@example.org"); + assertThat(new String(PgpEncryptorTest.decrypt(encryptedPart(toDave), DAVE))).contains("secret"); + } + + @Test + void staticSenderKeyProviderRejectsUnusableKey() { + org.assertj.core.api.Assertions.assertThatIllegalArgumentException().isThrownBy(() -> + new StaticSenderKeyProvider(Map.of("other@example.com", SENDER.toCertificate()), + new CertificateValidator())); + assertThat(SenderKeyProvider.none().keyFor("x@y.z")).isEqualTo(Optional.empty()); + } + + private static byte[] encryptedPart(MimeMessage message) throws Exception { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + message.writeTo(out); + MimeMessage parsed = new MimeMessage(SESSION, new ByteArrayInputStream(out.toByteArray())); + return ((Multipart) parsed.getContent()).getBodyPart(1).getInputStream().readAllBytes(); + } +} diff --git a/mailsender-pgp-core/src/test/java/pro/thinhha/mailsender/pgp/delivery/PolicyResolverTest.java b/mailsender-pgp-core/src/test/java/pro/thinhha/mailsender/pgp/delivery/PolicyResolverTest.java new file mode 100644 index 0000000..3eeac44 --- /dev/null +++ b/mailsender-pgp-core/src/test/java/pro/thinhha/mailsender/pgp/delivery/PolicyResolverTest.java @@ -0,0 +1,35 @@ +package pro.thinhha.mailsender.pgp.delivery; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +import java.util.Map; + +import org.junit.jupiter.api.Test; + +class PolicyResolverTest { + + private final PolicyResolver resolver = new PolicyResolver(EncryptionPolicy.OPPORTUNISTIC, Map.of( + "*@partner.com", EncryptionPolicy.REQUIRE, + "Press@Partner.com", EncryptionPolicy.PLAINTEXT, + "*@newsletter.example", EncryptionPolicy.PLAINTEXT)); + + @Test + void exactAddressWinsOverDomainWinsOverDefault() { + assertThat(resolver.resolve("press@partner.com")).isEqualTo(EncryptionPolicy.PLAINTEXT); + assertThat(resolver.resolve("CEO@PARTNER.COM")).isEqualTo(EncryptionPolicy.REQUIRE); + assertThat(resolver.resolve("someone@newsletter.example")).isEqualTo(EncryptionPolicy.PLAINTEXT); + assertThat(resolver.resolve("alice@example.org")).isEqualTo(EncryptionPolicy.OPPORTUNISTIC); + } + + @Test + void domainRuleDoesNotMatchSubdomains() { + assertThat(resolver.resolve("a@sub.partner.com")).isEqualTo(EncryptionPolicy.OPPORTUNISTIC); + } + + @Test + void rejectsRuleWithoutAt() { + assertThatIllegalArgumentException().isThrownBy(() -> + new PolicyResolver(EncryptionPolicy.OPPORTUNISTIC, Map.of("partner.com", EncryptionPolicy.REQUIRE))); + } +} diff --git a/mailsender-pgp-spring-boot-autoconfigure/pom.xml b/mailsender-pgp-spring-boot-autoconfigure/pom.xml index 851b9e5..b9a80ea 100644 --- a/mailsender-pgp-spring-boot-autoconfigure/pom.xml +++ b/mailsender-pgp-spring-boot-autoconfigure/pom.xml @@ -21,6 +21,11 @@ org.springframework.boot spring-boot-autoconfigure + + + org.springframework + spring-context-support + org.springframework.boot spring-boot-configuration-processor @@ -32,6 +37,16 @@ spring-boot-starter-test test + + org.springframework.boot + spring-boot-starter-mail + test + + + com.icegreen + greenmail-junit5 + test + diff --git a/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailAutoConfiguration.java b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailAutoConfiguration.java index 111954b..6deae4e 100644 --- a/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailAutoConfiguration.java +++ b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailAutoConfiguration.java @@ -7,8 +7,11 @@ import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Properties; import org.bouncycastle.openpgp.api.OpenPGPCertificate; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; @@ -17,8 +20,17 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Primary; import org.springframework.core.io.Resource; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.JavaMailSenderImpl; +import org.springframework.util.StringUtils; import pro.thinhha.mailsender.pgp.crypto.PgpEncryptor; +import pro.thinhha.mailsender.pgp.delivery.DeliveryPlanner; +import pro.thinhha.mailsender.pgp.delivery.MessageAssembler; +import pro.thinhha.mailsender.pgp.delivery.PolicyResolver; +import pro.thinhha.mailsender.pgp.delivery.RecipientKeyResolver; +import pro.thinhha.mailsender.pgp.delivery.SenderKeyProvider; +import pro.thinhha.mailsender.pgp.delivery.StaticSenderKeyProvider; import pro.thinhha.mailsender.pgp.discovery.CachingKeyDiscovery; import pro.thinhha.mailsender.pgp.discovery.Certificates; import pro.thinhha.mailsender.pgp.discovery.CompositeKeyDiscovery; @@ -29,24 +41,28 @@ 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.sender.BuiltInSecureMailSender; +import pro.thinhha.mailsender.pgp.sender.RelaySecureMailSender; +import pro.thinhha.mailsender.pgp.sender.SecureMailSender; import pro.thinhha.mailsender.pgp.validation.CertificateValidator; /** - * 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. + * Wires {@link SecureMailSender} and its parts: key discovery (pinned keys first, then WKD behind a cache), + * validation, policy, delivery planning and PGP/MIME encryption. Every bean backs off if the application defines + * its own. The primary {@link KeyDiscovery} bean is the combined chain; {@link CachingKeyDiscovery} is exposed + * separately so applications can evict entries. */ -@AutoConfiguration +@AutoConfiguration(afterName = "org.springframework.boot.mail.autoconfigure.MailSenderAutoConfiguration") @EnableConfigurationProperties(PgpMailProperties.class) @ConditionalOnProperty(prefix = "mail.pgp", name = "enabled", havingValue = "true", matchIfMissing = true) public class PgpMailAutoConfiguration { + private static final Logger log = LoggerFactory.getLogger(PgpMailAutoConfiguration.class); + @Bean @ConditionalOnMissingBean StaticKeyDiscovery pgpStaticKeyDiscovery(PgpMailProperties properties) { - Map> certificates = new LinkedHashMap<>(); - properties.staticKeys().forEach((email, resource) -> certificates.put(email, read(email, resource))); - return new StaticKeyDiscovery(certificates); + return new StaticKeyDiscovery(readAll("mail.pgp.static-keys", properties.staticKeys())); } @Bean @@ -97,15 +113,105 @@ public class PgpMailAutoConfiguration { return new PgpMimeEncryptor(encryptor); } - private static List read(String email, Resource resource) { + @Bean + @ConditionalOnMissingBean + PolicyResolver pgpPolicyResolver(PgpMailProperties properties) { + return new PolicyResolver(properties.defaultPolicy(), properties.recipients()); + } + + @Bean + @ConditionalOnMissingBean + RecipientKeyResolver pgpRecipientKeyResolver(KeyDiscovery discovery, CertificateValidator validator) { + return new RecipientKeyResolver(discovery, validator); + } + + @Bean + @ConditionalOnMissingBean + DeliveryPlanner pgpDeliveryPlanner(PgpMailProperties properties, PolicyResolver policies, + RecipientKeyResolver keys) { + return new DeliveryPlanner(policies, keys, properties.mixedMode()); + } + + @Bean + @ConditionalOnMissingBean + SenderKeyProvider pgpSenderKeyProvider(PgpMailProperties properties, CertificateValidator validator) { + Map keys = new LinkedHashMap<>(); + readAll("mail.pgp.sender-keys", properties.senderKeys()).forEach((address, certificates) -> { + if (certificates.size() != 1) { + throw new IllegalStateException( + "mail.pgp.sender-keys[" + address + "] must contain exactly one certificate"); + } + keys.put(address, certificates.getFirst()); + }); + if (keys.isEmpty() && properties.mode() == PgpMailProperties.Mode.BUILT_IN) { + log.info("No mail.pgp.sender-keys configured: encrypted mail will not be readable by its sender"); + } + return new StaticSenderKeyProvider(keys, validator); + } + + @Bean + @ConditionalOnMissingBean + MessageAssembler pgpMessageAssembler(PgpMimeEncryptor mimeEncryptor, SenderKeyProvider senderKeys) { + return new MessageAssembler(mimeEncryptor, senderKeys); + } + + @Bean + @ConditionalOnMissingBean + SecureMailSender pgpSecureMailSender(PgpMailProperties properties, ObjectProvider mailSender, + ObjectProvider planner, ObjectProvider assembler, + PolicyResolver policies) { + return switch (properties.mode()) { + case BUILT_IN -> { + JavaMailSender delegate = mailSender.getIfAvailable(); + if (delegate == null) { + throw new IllegalStateException( + "mail.pgp.mode=BUILT_IN needs a JavaMailSender bean; set spring.mail.host"); + } + yield new BuiltInSecureMailSender(delegate, planner.getObject(), assembler.getObject()); + } + case RELAY -> { + PgpMailProperties.Relay relay = properties.relay(); + log.info("mail.pgp.mode=RELAY: key discovery, encryption and policy enforcement are delegated to " + + "the gateway at {}:{}", relay.host(), relay.port()); + yield new RelaySecureMailSender(relaySender(relay), policies, relay.policyHeader()); + } + }; + } + + /** Not a bean: a second JavaMailSender would make Boot's own {@code spring.mail} sender back off. */ + private static JavaMailSenderImpl relaySender(PgpMailProperties.Relay relay) { + if (!StringUtils.hasText(relay.host())) { + throw new IllegalStateException("mail.pgp.mode=RELAY requires mail.pgp.relay.host"); + } + JavaMailSenderImpl sender = new JavaMailSenderImpl(); + sender.setHost(relay.host()); + sender.setPort(relay.port()); + sender.setUsername(relay.username()); + sender.setPassword(relay.password()); + Properties javaMail = new Properties(); + javaMail.setProperty("mail.smtp.auth", String.valueOf(StringUtils.hasText(relay.username()))); + javaMail.setProperty("mail.smtp.starttls.enable", String.valueOf(relay.starttls())); + javaMail.setProperty("mail.smtp.starttls.required", String.valueOf(relay.starttls())); + javaMail.putAll(relay.properties()); + sender.setJavaMailProperties(javaMail); + return sender; + } + + private static Map> readAll(String property, Map resources) { + Map> certificates = new LinkedHashMap<>(); + resources.forEach((email, resource) -> certificates.put(email, read(property, email, resource))); + return certificates; + } + + private static List read(String property, String email, Resource resource) { try (InputStream in = resource.getInputStream()) { List certificates = Certificates.parse(in.readAllBytes()); if (certificates.isEmpty()) { - throw new IllegalStateException("mail.pgp.static-keys[" + email + "]: no certificate in " + resource); + throw new IllegalStateException(property + "[" + email + "]: no certificate in " + resource); } return certificates; } catch (IOException e) { - throw new UncheckedIOException("mail.pgp.static-keys[" + email + "]: cannot read " + resource, e); + throw new UncheckedIOException(property + "[" + email + "]: cannot read " + resource, e); } } } diff --git a/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailProperties.java b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailProperties.java index fd04690..05a1fcf 100644 --- a/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailProperties.java +++ b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailProperties.java @@ -8,6 +8,8 @@ import org.springframework.boot.context.properties.bind.DefaultValue; import org.springframework.core.io.Resource; import org.springframework.util.unit.DataSize; +import pro.thinhha.mailsender.pgp.delivery.EncryptionPolicy; +import pro.thinhha.mailsender.pgp.delivery.MixedMode; import pro.thinhha.mailsender.pgp.discovery.WkdFallback; /** @@ -16,18 +18,38 @@ import pro.thinhha.mailsender.pgp.discovery.WkdFallback; * Map keys that are e-mail addresses must use bracket notation so Spring keeps the {@code @} and {@code .} * characters, e.g. {@code mail.pgp.static-keys[bob@legacy.org]=file:/etc/keys/bob.asc}. * - * @param enabled master switch - * @param staticKeys pinned certificates per address, checked before WKD + * @param enabled master switch + * @param mode encrypt in this application or hand mail to an encrypting relay + * @param defaultPolicy policy for recipients without a rule + * @param mixedMode what to do when only some recipients can get encrypted mail + * @param recipients policy rules per address or {@code *@domain} + * @param staticKeys pinned recipient certificates per address, checked before WKD + * @param senderKeys the sender's own public key per From address, so sent copies stay readable */ @ConfigurationProperties("mail.pgp") public record PgpMailProperties( @DefaultValue("true") boolean enabled, + @DefaultValue("BUILT_IN") Mode mode, + @DefaultValue("OPPORTUNISTIC") EncryptionPolicy defaultPolicy, + @DefaultValue("SPLIT") MixedMode mixedMode, + Map recipients, @DefaultValue Wkd wkd, @DefaultValue Cache cache, - Map staticKeys) { + Map staticKeys, + Map senderKeys, + @DefaultValue Relay relay) { public PgpMailProperties { + recipients = recipients == null ? Map.of() : Map.copyOf(recipients); staticKeys = staticKeys == null ? Map.of() : Map.copyOf(staticKeys); + senderKeys = senderKeys == null ? Map.of() : Map.copyOf(senderKeys); + } + + public enum Mode { + /** Discover keys and encrypt in this application, send through the application's {@code JavaMailSender}. */ + BUILT_IN, + /** Send plaintext to an encrypting gateway configured under {@code mail.pgp.relay}. */ + RELAY } public record Wkd( @@ -49,4 +71,25 @@ public record PgpMailProperties( @DefaultValue("5m") Duration errorTtl, @DefaultValue("10000") long maxSize) { } + + /** + * SMTP connection to the encrypting gateway ({@code mode=RELAY}). The hop carries plaintext, so STARTTLS is + * required by default. + * + * @param policyHeader header that carries the strictest recipient policy as a hint for the gateway; empty for none + * @param properties extra JavaMail properties, e.g. {@code mail.smtp.ssl.trust} + */ + public record Relay( + String host, + @DefaultValue("587") int port, + String username, + String password, + @DefaultValue("true") boolean starttls, + String policyHeader, + Map properties) { + + public Relay { + properties = properties == null ? Map.of() : Map.copyOf(properties); + } + } } diff --git a/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/sender/AbstractSecureMailSender.java b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/sender/AbstractSecureMailSender.java new file mode 100644 index 0000000..68d7b84 --- /dev/null +++ b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/sender/AbstractSecureMailSender.java @@ -0,0 +1,41 @@ +package pro.thinhha.mailsender.pgp.sender; + +import java.util.ArrayList; +import java.util.List; + +import org.springframework.mail.MailParseException; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.MimeMailMessage; + +import jakarta.mail.Address; +import jakarta.mail.Message; +import jakarta.mail.MessagingException; +import jakarta.mail.internet.InternetAddress; +import jakarta.mail.internet.MimeMessage; + +abstract class AbstractSecureMailSender implements SecureMailSender { + + @Override + public SendReport send(SimpleMailMessage simpleMessage) { + MimeMailMessage message = new MimeMailMessage(createMimeMessage()); + simpleMessage.copyTo(message); + return send(message.getMimeMessage()); + } + + static List recipients(MimeMessage message, Message.RecipientType type) { + try { + Address[] addresses = message.getRecipients(type); + List result = new ArrayList<>(); + if (addresses != null) { + for (Address address : addresses) { + if (address instanceof InternetAddress internet) { + result.add(internet); + } + } + } + return result; + } catch (MessagingException e) { + throw new MailParseException("Cannot read " + type + " recipients", e); + } + } +} diff --git a/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/sender/BuiltInSecureMailSender.java b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/sender/BuiltInSecureMailSender.java new file mode 100644 index 0000000..c9805ff --- /dev/null +++ b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/sender/BuiltInSecureMailSender.java @@ -0,0 +1,76 @@ +package pro.thinhha.mailsender.pgp.sender; + +import java.io.IOException; +import java.util.List; +import java.util.Objects; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.mail.MailPreparationException; +import org.springframework.mail.javamail.JavaMailSender; + +import jakarta.mail.Message; +import jakarta.mail.MessagingException; +import jakarta.mail.internet.MimeMessage; +import pro.thinhha.mailsender.pgp.delivery.DeliveryPlan; +import pro.thinhha.mailsender.pgp.delivery.DeliveryPlanner; +import pro.thinhha.mailsender.pgp.delivery.MessageAssembler; +import pro.thinhha.mailsender.pgp.delivery.RecipientDecision; +import pro.thinhha.mailsender.pgp.delivery.RecipientKey; + +/** Encrypts in this application: plan per recipient, build the copies, send them over one SMTP connection. */ +public final class BuiltInSecureMailSender extends AbstractSecureMailSender { + + private static final Logger log = LoggerFactory.getLogger(BuiltInSecureMailSender.class); + + private final JavaMailSender delegate; + private final DeliveryPlanner planner; + private final MessageAssembler assembler; + + public BuiltInSecureMailSender(JavaMailSender delegate, DeliveryPlanner planner, MessageAssembler assembler) { + this.delegate = Objects.requireNonNull(delegate, "delegate"); + this.planner = Objects.requireNonNull(planner, "planner"); + this.assembler = Objects.requireNonNull(assembler, "assembler"); + } + + @Override + public MimeMessage createMimeMessage() { + return delegate.createMimeMessage(); + } + + @Override + public SendReport send(MimeMessage message) { + DeliveryPlan plan = planner.plan( + recipients(message, Message.RecipientType.TO), + recipients(message, Message.RecipientType.CC), + recipients(message, Message.RecipientType.BCC)); + + List copies; + try { + copies = assembler.assemble(message, plan, delegate::createMimeMessage); + } catch (MessagingException | IOException e) { + throw new MailPreparationException("Could not build encrypted message", e); + } + + plan.decisions().stream().filter(RecipientDecision::isDowngrade).forEach(d -> + log.warn("Sending to {} in PLAINTEXT (policy {}): {}", d.address(), d.policy(), describe(d.key()))); + delegate.send(copies.toArray(MimeMessage[]::new)); + + SendReport report = new SendReport(plan.decisions().stream() + .map(d -> new SendReport.RecipientOutcome(d.address(), d.type(), d.policy(), + d.encrypted() ? SendReport.Outcome.ENCRYPTED : SendReport.Outcome.PLAINTEXT, + d.encrypted() ? null : describe(d.key()))) + .toList()); + log.debug("Sent {} cop{}: {}", copies.size(), copies.size() == 1 ? "y" : "ies", report); + return report; + } + + private static String describe(RecipientKey key) { + return switch (key) { + case RecipientKey.Usable usable -> "key from " + usable.source(); + case RecipientKey.Missing missing -> + missing.detail() == null ? missing.reason().toString() : missing.reason() + ": " + missing.detail(); + case RecipientKey.NotLookedUp ignored -> "plaintext policy"; + }; + } +} diff --git a/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/sender/RelaySecureMailSender.java b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/sender/RelaySecureMailSender.java new file mode 100644 index 0000000..d20aa9f --- /dev/null +++ b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/sender/RelaySecureMailSender.java @@ -0,0 +1,71 @@ +package pro.thinhha.mailsender.pgp.sender; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.Objects; + +import org.springframework.mail.MailPreparationException; +import org.springframework.mail.javamail.JavaMailSender; + +import jakarta.mail.Message; +import jakarta.mail.MessagingException; +import jakarta.mail.internet.InternetAddress; +import jakarta.mail.internet.MimeMessage; +import pro.thinhha.mailsender.pgp.delivery.EncryptionPolicy; +import pro.thinhha.mailsender.pgp.delivery.PolicyResolver; +import pro.thinhha.mailsender.pgp.delivery.RecipientType; + +/** + * Hands the plaintext message to an encrypting gateway (for example a GnuPG-based filter using + * {@code --auto-key-locate wkd}). Key discovery, encryption and policy enforcement happen there; this sender can + * only pass the strictest recipient policy along as a header hint. + */ +public final class RelaySecureMailSender extends AbstractSecureMailSender { + + private final JavaMailSender relay; + private final PolicyResolver policies; + private final String policyHeader; + + /** @param policyHeader header carrying the policy hint, or {@code null} for none */ + public RelaySecureMailSender(JavaMailSender relay, PolicyResolver policies, String policyHeader) { + this.relay = Objects.requireNonNull(relay, "relay"); + this.policies = Objects.requireNonNull(policies, "policies"); + this.policyHeader = policyHeader == null || policyHeader.isBlank() ? null : policyHeader; + } + + @Override + public MimeMessage createMimeMessage() { + return relay.createMimeMessage(); + } + + @Override + public SendReport send(MimeMessage message) { + List outcomes = new ArrayList<>(); + addOutcomes(outcomes, recipients(message, Message.RecipientType.TO), RecipientType.TO); + addOutcomes(outcomes, recipients(message, Message.RecipientType.CC), RecipientType.CC); + addOutcomes(outcomes, recipients(message, Message.RecipientType.BCC), RecipientType.BCC); + + if (policyHeader != null && !outcomes.isEmpty()) { + // Enum order is strictest first: REQUIRE, OPPORTUNISTIC, PLAINTEXT. + EncryptionPolicy strictest = outcomes.stream().map(SendReport.RecipientOutcome::policy) + .min(Comparator.naturalOrder()).orElseThrow(); + try { + message.setHeader(policyHeader, strictest.name().toLowerCase(Locale.ROOT)); + } catch (MessagingException e) { + throw new MailPreparationException("Could not set " + policyHeader, e); + } + } + relay.send(message); + return new SendReport(outcomes); + } + + private void addOutcomes(List outcomes, List addresses, + RecipientType type) { + for (InternetAddress address : addresses) { + outcomes.add(new SendReport.RecipientOutcome(address.getAddress(), type, + policies.resolve(address.getAddress()), SendReport.Outcome.RELAYED, null)); + } + } +} diff --git a/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/sender/SecureMailSender.java b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/sender/SecureMailSender.java new file mode 100644 index 0000000..a20071c --- /dev/null +++ b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/sender/SecureMailSender.java @@ -0,0 +1,25 @@ +package pro.thinhha.mailsender.pgp.sender; + +import org.springframework.mail.MailException; +import org.springframework.mail.SimpleMailMessage; + +import jakarta.mail.internet.MimeMessage; +import pro.thinhha.mailsender.pgp.delivery.EncryptionPolicyException; + +/** + * Sends mail with OpenPGP encryption according to the configured policy. Build the message as you would for + * {@code JavaMailSender} (plaintext); the sender decides per recipient whether it goes out encrypted. + */ +public interface SecureMailSender { + + MimeMessage createMimeMessage(); + + /** + * @throws EncryptionPolicyException if the policy forbids sending (nothing is sent) + * @throws MailException on preparation or SMTP failures + */ + SendReport send(MimeMessage message); + + /** @see #send(MimeMessage) */ + SendReport send(SimpleMailMessage message); +} diff --git a/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/sender/SendReport.java b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/sender/SendReport.java new file mode 100644 index 0000000..3789548 --- /dev/null +++ b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/sender/SendReport.java @@ -0,0 +1,34 @@ +package pro.thinhha.mailsender.pgp.sender; + +import java.util.List; + +import pro.thinhha.mailsender.pgp.delivery.EncryptionPolicy; +import pro.thinhha.mailsender.pgp.delivery.RecipientType; + +/** What happened to each recipient of a sent message. */ +public record SendReport(List recipients) { + + public SendReport { + recipients = List.copyOf(recipients); + } + + public boolean allEncrypted() { + return recipients.stream().allMatch(r -> r.outcome() == Outcome.ENCRYPTED); + } + + /** + * @param detail why the recipient got plaintext (missing key reason), or {@code null} + */ + public record RecipientOutcome(String address, RecipientType type, EncryptionPolicy policy, Outcome outcome, + String detail) { + } + + public enum Outcome { + /** Sent PGP/MIME-encrypted by this application. */ + ENCRYPTED, + /** Sent in plaintext by this application. */ + PLAINTEXT, + /** Handed to the encrypting relay, which decides. */ + RELAYED + } +} diff --git a/mailsender-pgp-spring-boot-autoconfigure/src/test/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailAutoConfigurationTest.java b/mailsender-pgp-spring-boot-autoconfigure/src/test/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailAutoConfigurationTest.java index 0c02c2d..6ed73d9 100644 --- a/mailsender-pgp-spring-boot-autoconfigure/src/test/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailAutoConfigurationTest.java +++ b/mailsender-pgp-spring-boot-autoconfigure/src/test/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailAutoConfigurationTest.java @@ -11,7 +11,9 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import org.pgpainless.PGPainless; import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.mail.autoconfigure.MailSenderAutoConfiguration; import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.mail.javamail.JavaMailSender; import pro.thinhha.mailsender.pgp.discovery.CachingKeyDiscovery; import pro.thinhha.mailsender.pgp.discovery.DiscoveryResult; @@ -20,12 +22,16 @@ 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.sender.BuiltInSecureMailSender; +import pro.thinhha.mailsender.pgp.sender.RelaySecureMailSender; +import pro.thinhha.mailsender.pgp.sender.SecureMailSender; import pro.thinhha.mailsender.pgp.validation.CertificateValidator; class PgpMailAutoConfigurationTest { private final ApplicationContextRunner runner = new ApplicationContextRunner() - .withConfiguration(AutoConfigurations.of(PgpMailAutoConfiguration.class)); + .withConfiguration(AutoConfigurations.of(MailSenderAutoConfiguration.class, PgpMailAutoConfiguration.class)) + .withPropertyValues("spring.mail.host=localhost"); @TempDir Path tempDir; @@ -70,6 +76,48 @@ class PgpMailAutoConfigurationTest { .run(context -> assertThat(context).hasFailed()); } + @Test + void builtInModeWrapsTheApplicationsMailSender() { + runner.run(context -> assertThat(context.getBean(SecureMailSender.class)) + .isInstanceOf(BuiltInSecureMailSender.class)); + } + + @Test + void builtInModeWithoutMailSenderFailsWithHint() { + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(PgpMailAutoConfiguration.class)) + .run(context -> assertThat(context).hasFailed().getFailure() + .rootCause().hasMessageContaining("spring.mail.host")); + } + + @Test + void relayModeKeepsTheApplicationsMailSender() { + runner.withPropertyValues("mail.pgp.mode=relay", "mail.pgp.relay.host=gateway.internal") + .run(context -> { + assertThat(context.getBean(SecureMailSender.class)).isInstanceOf(RelaySecureMailSender.class); + assertThat(context).hasSingleBean(JavaMailSender.class); + assertThat(context.getBean(JavaMailSender.class)).extracting("host").isEqualTo("localhost"); + }); + } + + @Test + void relayModeRequiresHost() { + runner.withPropertyValues("mail.pgp.mode=RELAY") + .run(context -> assertThat(context).hasFailed().getFailure() + .rootCause().hasMessageContaining("mail.pgp.relay.host")); + } + + @Test + void senderKeyMustMatchItsAddress() throws IOException { + Path key = tempDir.resolve("sender.asc"); + Files.writeString(key, PGPainless.getInstance().generateKey() + .modernKeyRing("").toCertificate().toAsciiArmoredString()); + + runner.withPropertyValues("mail.pgp.sender-keys[noreply@example.com]=file:" + key) + .run(context -> assertThat(context).hasFailed().getFailure() + .rootCause().hasMessageContaining("USER_ID_MISMATCH")); + } + @Test void disabledCreatesNothing() { runner.withPropertyValues("mail.pgp.enabled=false") diff --git a/mailsender-pgp-spring-boot-autoconfigure/src/test/java/pro/thinhha/mailsender/pgp/sender/SecureMailSenderIntegrationTest.java b/mailsender-pgp-spring-boot-autoconfigure/src/test/java/pro/thinhha/mailsender/pgp/sender/SecureMailSenderIntegrationTest.java new file mode 100644 index 0000000..a1e7699 --- /dev/null +++ b/mailsender-pgp-spring-boot-autoconfigure/src/test/java/pro/thinhha/mailsender/pgp/sender/SecureMailSenderIntegrationTest.java @@ -0,0 +1,170 @@ +package pro.thinhha.mailsender.pgp.sender; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import org.bouncycastle.openpgp.api.OpenPGPKey; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.pgpainless.PGPainless; +import org.pgpainless.decryption_verification.ConsumerOptions; +import org.pgpainless.decryption_verification.DecryptionStream; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.mail.autoconfigure.MailSenderAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.mail.SimpleMailMessage; + +import com.icegreen.greenmail.util.GreenMail; +import com.icegreen.greenmail.util.ServerSetupTest; + +import jakarta.mail.Message; +import jakarta.mail.Multipart; +import jakarta.mail.internet.MimeMessage; +import pro.thinhha.mailsender.pgp.autoconfigure.PgpMailAutoConfiguration; +import pro.thinhha.mailsender.pgp.delivery.EncryptionPolicyException; + +/** End to end through the auto-configured {@link SecureMailSender} into GreenMail. WKD is off; keys are pinned. */ +class SecureMailSenderIntegrationTest { + + private static final PGPainless API = PGPainless.getInstance(); + private static final OpenPGPKey ALICE = API.generateKey().modernKeyRing(""); + private static final OpenPGPKey DAVE = API.generateKey().modernKeyRing(""); + private static final OpenPGPKey SENDER = API.generateKey().modernKeyRing(""); + + @TempDir + Path keys; + private GreenMail greenMail; + private ApplicationContextRunner runner; + + @BeforeEach + void setUp() throws Exception { + greenMail = new GreenMail(ServerSetupTest.SMTP.dynamicPort()); + greenMail.start(); + runner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(MailSenderAutoConfiguration.class, + PgpMailAutoConfiguration.class)) + .withPropertyValues( + "spring.mail.host=127.0.0.1", + "spring.mail.port=" + greenMail.getSmtp().getPort(), + "mail.pgp.wkd.enabled=false", + "mail.pgp.static-keys[alice@example.org]=file:" + write("alice", ALICE), + "mail.pgp.static-keys[dave@example.org]=file:" + write("dave", DAVE), + "mail.pgp.sender-keys[app@example.com]=file:" + write("app", SENDER), + "mail.pgp.recipients[*@partner.example]=REQUIRE"); + } + + @AfterEach + void tearDown() { + greenMail.stop(); + } + + @Test + void splitsMixedRecipientsAndReportsEachOne() { + runner.run(context -> { + SecureMailSender sender = context.getBean(SecureMailSender.class); + SimpleMailMessage message = new SimpleMailMessage(); + message.setFrom("app@example.com"); + message.setTo("alice@example.org", "carol@example.org"); + message.setBcc("dave@example.org"); + message.setSubject("Report"); + message.setText("top secret"); + + SendReport report = sender.send(message); + + assertThat(report.recipients()).extracting(SendReport.RecipientOutcome::address, + SendReport.RecipientOutcome::outcome).containsExactly( + org.assertj.core.groups.Tuple.tuple("alice@example.org", SendReport.Outcome.ENCRYPTED), + org.assertj.core.groups.Tuple.tuple("carol@example.org", SendReport.Outcome.PLAINTEXT), + org.assertj.core.groups.Tuple.tuple("dave@example.org", SendReport.Outcome.ENCRYPTED)); + assertThat(report.recipients().get(1).detail()).isEqualTo("NOT_FOUND"); + + MimeMessage alice = inbox("alice@example.org"); + MimeMessage carol = inbox("carol@example.org"); + MimeMessage dave = inbox("dave@example.org"); + assertThat(greenMail.getReceivedMessages()).hasSize(3); + + assertThat(alice.isMimeType("multipart/encrypted")).isTrue(); + assertThat(decrypt(alice, ALICE)).contains("top secret"); + assertThat(decrypt(alice, SENDER)).as("sender can read sent copy").contains("top secret"); + assertThat(carol.isMimeType("text/plain")).isTrue(); + assertThat(carol.getContent().toString()).contains("top secret"); + assertThat(decrypt(dave, DAVE)).contains("top secret"); + + for (MimeMessage received : List.of(alice, carol, dave)) { + assertThat(received.getHeader("Bcc")).isNull(); + assertThat(received.getRecipients(Message.RecipientType.TO)).hasSize(2); + assertThat(received.getMessageID()).isEqualTo(alice.getMessageID()); + } + }); + } + + @Test + void refusesRequiredRecipientWithoutKeyAndSendsNothing() { + runner.run(context -> { + SimpleMailMessage message = new SimpleMailMessage(); + message.setFrom("app@example.com"); + message.setTo("alice@example.org", "ceo@partner.example"); + message.setText("x"); + + assertThatThrownBy(() -> context.getBean(SecureMailSender.class).send(message)) + .isInstanceOf(EncryptionPolicyException.class).hasMessageContaining("ceo@partner.example"); + assertThat(greenMail.getReceivedMessages()).isEmpty(); + }); + } + + @Test + void relayModeSendsPlaintextWithPolicyHint() { + runner.withPropertyValues( + "mail.pgp.mode=RELAY", + "mail.pgp.relay.host=127.0.0.1", + "mail.pgp.relay.port=" + greenMail.getSmtp().getPort(), + "mail.pgp.relay.starttls=false", + "mail.pgp.relay.policy-header=X-Encryption-Policy") + .run(context -> { + SimpleMailMessage message = new SimpleMailMessage(); + message.setFrom("app@example.com"); + message.setTo("alice@example.org", "ceo@partner.example"); + message.setText("via gateway"); + + SendReport report = context.getBean(SecureMailSender.class).send(message); + + assertThat(report.recipients()).allMatch(r -> r.outcome() == SendReport.Outcome.RELAYED); + MimeMessage received = greenMail.getReceivedMessages()[0]; + assertThat(received.getHeader("X-Encryption-Policy")).containsExactly("require"); + assertThat(received.getContent().toString()).contains("via gateway"); + }); + } + + private Path write(String name, OpenPGPKey key) throws Exception { + Path file = keys.resolve(name + ".asc"); + Files.writeString(file, key.toCertificate().toAsciiArmoredString()); + return file; + } + + /** The single message delivered to this address's mailbox, i.e. via the SMTP envelope. */ + private MimeMessage inbox(String address) throws Exception { + var user = greenMail.getUserManager().getUserByEmail(address); + assertThat(user).as("mailbox for " + address).isNotNull(); + var stored = greenMail.getManagers().getImapHostManager().getInbox(user).getMessages(); + assertThat(stored).hasSize(1); + return stored.getFirst().getMimeMessage(); + } + + private static String decrypt(MimeMessage message, OpenPGPKey key) throws Exception { + byte[] armored = ((Multipart) message.getContent()).getBodyPart(1).getInputStream().readAllBytes(); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + try (DecryptionStream in = API.processMessage().onInputStream(new ByteArrayInputStream(armored)) + .withOptions(ConsumerOptions.get(API).addDecryptionKey(key))) { + in.transferTo(out); + } + return out.toString(java.nio.charset.StandardCharsets.UTF_8); + } +}