From 213df4c34ec208b09a31edb3834a65c19561939c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 03:13:23 +0000 Subject: [PATCH 1/5] Scaffold PGP mail modules with WKD key discovery and demo app - Maven multi-module build on JDK 27 and Spring Boot 4.1.1 (BOM import) - core: WKD discovery (advanced -> direct fallback), HTTP fetcher with timeouts, no redirects and a size limit, Caffeine cache with per-outcome TTLs capped at key expiry, pinned keys, composite chain - autoconfigure: mail.pgp.* properties and discovery beans - demo: sends test mails (plaintext for now) and reports the WKD result - docs/PLAN.md: design, decisions and milestones Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G71TQ5jDETVwdUJxGAwaiT --- .gitignore | 5 + README.md | 43 +++++ docs/PLAN.md | 175 ++++++++++++++++++ mailsender-demo/pom.xml | 54 ++++++ .../mailsender/demo/DemoApplication.java | 14 ++ .../mailsender/demo/DemoProperties.java | 16 ++ .../mailsender/demo/TestMailRunner.java | 76 ++++++++ .../src/main/resources/application.yml | 23 +++ .../mailsender/demo/TestMailRunnerTest.java | 59 ++++++ mailsender-pgp-core/pom.xml | 50 +++++ .../pgp/discovery/CachingKeyDiscovery.java | 121 ++++++++++++ .../pgp/discovery/Certificates.java | 23 +++ .../pgp/discovery/CompositeKeyDiscovery.java | 34 ++++ .../pgp/discovery/DiscoveryResult.java | 36 ++++ .../pgp/discovery/DiscoverySource.java | 11 ++ .../pgp/discovery/HostResolver.java | 22 +++ .../pgp/discovery/HttpWkdFetcher.java | 64 +++++++ .../pgp/discovery/KeyDiscovery.java | 19 ++ .../pgp/discovery/StaticKeyDiscovery.java | 34 ++++ .../mailsender/pgp/discovery/WkdFallback.java | 15 ++ .../mailsender/pgp/discovery/WkdFetcher.java | 21 +++ .../pgp/discovery/WkdKeyDiscovery.java | 96 ++++++++++ .../discovery/CachingKeyDiscoveryTest.java | 109 +++++++++++ .../discovery/CompositeKeyDiscoveryTest.java | 44 +++++ .../pgp/discovery/HttpWkdFetcherTest.java | 85 +++++++++ .../mailsender/pgp/discovery/TestKeys.java | 37 ++++ .../pgp/discovery/WkdAddressTest.java | 21 +++ .../pgp/discovery/WkdKeyDiscoveryTest.java | 160 ++++++++++++++++ .../pom.xml | 54 ++++++ .../PgpMailAutoConfiguration.java | 89 +++++++++ .../pgp/autoconfigure/PgpMailProperties.java | 52 ++++++ ...ot.autoconfigure.AutoConfiguration.imports | 1 + .../PgpMailAutoConfigurationTest.java | 75 ++++++++ mailsender-pgp-spring-boot-starter/pom.xml | 26 +++ pom.xml | 121 ++++++++++++ 35 files changed, 1885 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 docs/PLAN.md create mode 100644 mailsender-demo/pom.xml create mode 100644 mailsender-demo/src/main/java/io/github/sneakybugmaster/mailsender/demo/DemoApplication.java create mode 100644 mailsender-demo/src/main/java/io/github/sneakybugmaster/mailsender/demo/DemoProperties.java create mode 100644 mailsender-demo/src/main/java/io/github/sneakybugmaster/mailsender/demo/TestMailRunner.java create mode 100644 mailsender-demo/src/main/resources/application.yml create mode 100644 mailsender-demo/src/test/java/io/github/sneakybugmaster/mailsender/demo/TestMailRunnerTest.java create mode 100644 mailsender-pgp-core/pom.xml create mode 100644 mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/CachingKeyDiscovery.java create mode 100644 mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/Certificates.java create mode 100644 mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/CompositeKeyDiscovery.java create mode 100644 mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/DiscoveryResult.java create mode 100644 mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/DiscoverySource.java create mode 100644 mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/HostResolver.java create mode 100644 mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/HttpWkdFetcher.java create mode 100644 mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/KeyDiscovery.java create mode 100644 mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/StaticKeyDiscovery.java create mode 100644 mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/WkdFallback.java create mode 100644 mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/WkdFetcher.java create mode 100644 mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/WkdKeyDiscovery.java create mode 100644 mailsender-pgp-core/src/test/java/io/github/sneakybugmaster/mailsender/pgp/discovery/CachingKeyDiscoveryTest.java create mode 100644 mailsender-pgp-core/src/test/java/io/github/sneakybugmaster/mailsender/pgp/discovery/CompositeKeyDiscoveryTest.java create mode 100644 mailsender-pgp-core/src/test/java/io/github/sneakybugmaster/mailsender/pgp/discovery/HttpWkdFetcherTest.java create mode 100644 mailsender-pgp-core/src/test/java/io/github/sneakybugmaster/mailsender/pgp/discovery/TestKeys.java create mode 100644 mailsender-pgp-core/src/test/java/io/github/sneakybugmaster/mailsender/pgp/discovery/WkdAddressTest.java create mode 100644 mailsender-pgp-core/src/test/java/io/github/sneakybugmaster/mailsender/pgp/discovery/WkdKeyDiscoveryTest.java create mode 100644 mailsender-pgp-spring-boot-autoconfigure/pom.xml create mode 100644 mailsender-pgp-spring-boot-autoconfigure/src/main/java/io/github/sneakybugmaster/mailsender/pgp/autoconfigure/PgpMailAutoConfiguration.java create mode 100644 mailsender-pgp-spring-boot-autoconfigure/src/main/java/io/github/sneakybugmaster/mailsender/pgp/autoconfigure/PgpMailProperties.java create mode 100644 mailsender-pgp-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 mailsender-pgp-spring-boot-autoconfigure/src/test/java/io/github/sneakybugmaster/mailsender/pgp/autoconfigure/PgpMailAutoConfigurationTest.java create mode 100644 mailsender-pgp-spring-boot-starter/pom.xml create mode 100644 pom.xml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a2b7bde --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +target/ +.idea/ +*.iml +.vscode/ +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..80cbb97 --- /dev/null +++ b/README.md @@ -0,0 +1,43 @@ +# mailsender + +OpenPGP encryption for Spring Boot mail: discovers recipients' keys via Web Key Directory (WKD) and sends +PGP/MIME (RFC 3156), or hands mail to an encrypting relay. See [docs/PLAN.md](docs/PLAN.md) for the design +and milestone status. + +Requires JDK 27 and Maven 3.9+. Built on Spring Boot 4.1.1 and PGPainless 2.0. + +``` +mvn verify +``` + +## Modules + +| 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-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. + +``` +mvn -q install -DskipTests +SMTP_HOST=smtp.example.com SMTP_PORT=587 \ +SMTP_USERNAME=me@example.com SMTP_PASSWORD='app-password' \ +DEMO_TO=test@thinhha.pro DEMO_COUNT=1 \ +java -jar mailsender-demo/target/mailsender-demo-0.1.0-SNAPSHOT.jar +``` + +| Variable | Default | Meaning | +|---|---|---| +| `SMTP_HOST` / `SMTP_PORT` | `localhost` / `587` | SMTP server | +| `SMTP_USERNAME` / `SMTP_PASSWORD` | empty | SMTP credentials | +| `SMTP_AUTH` / `SMTP_STARTTLS` | `true` / `true` | set to `false` for a local test server | +| `DEMO_TO` | `test@thinhha.pro` | recipient | +| `DEMO_FROM` | `SMTP_USERNAME` | sender address | +| `DEMO_COUNT` | `1` | number of mails | diff --git a/docs/PLAN.md b/docs/PLAN.md new file mode 100644 index 0000000..86c6b90 --- /dev/null +++ b/docs/PLAN.md @@ -0,0 +1,175 @@ +# mailsender — OpenPGP (WKD + PGP/MIME) for Spring Boot + +A Spring Boot starter that lets an application keep using `JavaMailSender`-style sending while +mail to recipients with a published OpenPGP key goes out encrypted. The application can either +encrypt in-process ("built-in") or hand mail to an encrypting relay/gateway. + +## Decisions + +| Topic | Decision | +|---|---| +| Build | Maven, multi-module | +| Java | JDK 27 (`maven.compiler.release=27`) | +| Spring Boot | 4.1.1 (imported as BOM, not as parent, so consumers are not forced onto our parent) | +| Crypto | PGPainless 2.0.x (on Bouncy Castle 1.84, `OpenPGPCertificate` API) | +| WKD hashing/URLs | `org.pgpainless:wkd-java` 0.1.3 (`WKDAddress`). Note: there is no `pgpainless-wkd` artifact on Maven Central; `wkd-java` provides the hashing/URL building. Fetching is ours (JDK `HttpClient`) so we control timeouts, size limits, redirects and fallback. | +| Default policy | `OPPORTUNISTIC` | +| Mixed recipients | `SPLIT` | +| Readable sent copies | Only if the client configures its own public key for the sender address. No key → sent copy is not decryptable by the sender (logged once at INFO). | +| Encryption mode | Client chooses `BUILT_IN` or `RELAY` via `mail.pgp.mode`. A custom `SecureMailSender` bean replaces both. | + +## Modules + +``` +mailsender/ +├── pom.xml parent; imports spring-boot-dependencies 4.1.1 +├── mailsender-pgp-core/ plain Java, no Spring +│ discovery/ KeyDiscovery, DiscoveryResult, WkdKeyDiscovery, WkdFetcher, +│ HttpWkdFetcher, HostResolver, CachingKeyDiscovery, +│ StaticKeyDiscovery, CompositeKeyDiscovery +│ validation/ CertificateValidator, ValidationResult (M3) +│ crypto/ PgpEncryptor (M4) +│ mime/ PgpMimeBuilder, EncryptedMultipart, EnvelopeMimeMessage (M4) +│ policy/ EncryptionPolicy, PolicyResolver, RecipientPlan, +│ DeliveryPlanner (M5) +├── mailsender-pgp-spring-boot-autoconfigure/ +│ PgpMailProperties, PgpMailAutoConfiguration, +│ SecureMailSender (+ BuiltInSecureMailSender, RelaySecureMailSender) (M6) +├── mailsender-pgp-spring-boot-starter/ dependencies only +└── mailsender-demo/ sample app + GreenMail E2E (M7) +``` + +## Pipeline (BUILT_IN mode) + +``` +SecureMailSender.send(message) + └─ collect To/Cc/Bcc + └─ for each recipient: PolicyResolver → policy; KeyDiscovery (cached) → cert?; CertificateValidator + └─ DeliveryPlanner → 1..n deliveries: + encrypted group (To/Cc with valid keys) → 1 encrypted message + plaintext group (OPPORTUNISTIC/PLAINTEXT, no key) → 1 plaintext message (WARN + metric) + each Bcc recipient → own copy (encrypted if key) + any REQUIRE recipient without key → EncryptionRequiredException, nothing sent + └─ per encrypted delivery: inner MIME → canonical CRLF bytes → encrypt to recipients + (+ sender's self key if configured) → PGP/MIME (RFC 3156) wrapper + └─ JavaMailSender.send(EnvelopeMimeMessage) — returns SendReport +``` + +### Key discovery (M2) +- `WKDAddress.fromEmail(addr)` builds both URLs (lowercased local part → SHA-1 → z-base-32, `?l=`). +- Advanced method first. Fall back to direct per the WKD draft only when the `openpgpkey.` host + does not resolve (`fallback: ON_DNS_FAILURE`, default). `ON_ANY_FAILURE` is available for the many + domains with wildcard DNS whose advanced host answers 404 or has a broken TLS cert. +- HTTP: HTTPS only, no redirects, connect/read timeouts, max response size. +- Result is a sealed type: `Found(certs, method, uri)`, `NotFound`, `Unreachable(reason)`. + The policy layer treats `Unreachable` like `NotFound` for OPPORTUNISTIC recipients but logs it + separately so outages are visible. +- `CachingKeyDiscovery` (Caffeine): positive TTL (24h, capped at certificate expiry), negative TTL (1h), + error TTL (5m). An entry can be evicted by address (key rotation). +- `StaticKeyDiscovery`: client-pinned keys, checked before WKD via `CompositeKeyDiscovery`. + +### Validation (M3) +A certificate is usable for `addr` only if: not revoked/expired; has a valid encryption-capable +(communications) subkey; has a valid UID containing `addr` (domain case-insensitive); meets the +algorithm policy (e.g. RSA ≥ 2048). Rejections carry a reason for logs/metrics. + +### Encryption + PGP/MIME (M4) +- PGPainless `generateMessage()` with `EncryptionOptions.encryptCommunications()`, ASCII armor on. +- Inner entity = the original body (text/html/attachments) with its own `Content-Type` headers, + serialized with `writeTo()`, CRLF-canonicalized, then encrypted. +- Outer entity: `multipart/encrypted; protocol="application/pgp-encrypted"` with + part 1 `application/pgp-encrypted` = `Version: 1`, part 2 `application/octet-stream; name="encrypted.asc"`. +- `EncryptedMultipart` subclasses `MimeMultipart` to set the content type itself, otherwise + `saveChanges()` drops the `protocol` parameter. +- Protected headers (RFC 9788) opt-in: `mail.pgp.protected-headers=true` moves the real Subject inside and + sets the outer one to `...`. + +### Split delivery + envelope (M5) +`SPLIT` copies keep the original `To`/`Cc` headers so every recipient sees the full conversation, but each +copy must only be delivered to its subgroup. `JavaMailSenderImpl` uses `MimeMessage.getAllRecipients()` +as the SMTP envelope, so `EnvelopeMimeMessage` overrides that method to return only the subgroup. +Bcc recipients always get their own copy (avoids leaking them in headers or key IDs). + +### Sent copies (encrypt-to-self) +```yaml +mail.pgp.sender-keys: + "[noreply@acme.com]": classpath:keys/noreply.pub.asc + "[billing@acme.com]": file:/etc/mail/billing.pub.asc +``` +Or a `SenderKeyProvider` bean. Keys are validated at startup (fail fast if unusable). If the `From` +address has no configured key, nothing is added and the sent copy cannot be decrypted by the sender. + +## RELAY mode (M6) + +The application sends plaintext to an encrypting gateway (e.g. a GnuPG-based MTA filter using +`--auto-key-locate wkd`, or a commercial mail gateway); key discovery and encryption happen there. + +```yaml +mail.pgp: + mode: RELAY + relay: + host: gateway.internal + port: 587 + username: app + password: ${RELAY_PASSWORD} + starttls: required # the hop to the gateway carries plaintext — must be TLS + policy-header: X-Encryption-Policy # optional; per-recipient policy hint for the gateway +``` +- `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. +- Encrypt-to-self is the gateway's responsibility in this mode. + +## Configuration (full shape) + +Map keys that are addresses must be written in brackets (`"[bob@legacy.org]"`); otherwise Spring's relaxed +binding strips the `@` and `.` characters. + +```yaml +mail.pgp: + enabled: true + mode: BUILT_IN # BUILT_IN | RELAY + default-policy: OPPORTUNISTIC # REQUIRE | OPPORTUNISTIC | PLAINTEXT + mixed-mode: SPLIT # SPLIT | FAIL + protected-headers: false + recipients: # exact address or *@domain + "[ceo@example.org]": REQUIRE + "[*@partner.com]": REQUIRE + "[*@newsletter.example]": PLAINTEXT + static-keys: + "[bob@legacy.org]": file:/etc/keys/bob.asc + sender-keys: {} + wkd: + enabled: true + fallback: ON_DNS_FAILURE # ON_DNS_FAILURE | ON_ANY_FAILURE + connect-timeout: 3s + read-timeout: 5s + max-response-size: 256KB + cache: + ttl: 24h + negative-ttl: 1h + error-ttl: 5m + max-size: 10000 + relay: { ... } # see RELAY mode +``` + +## Observability +- `mail.pgp.sent{mode=encrypted|plaintext}`, `mail.pgp.downgrade{reason=no_key|unreachable|invalid_key}` +- `mail.pgp.wkd.lookup{result=found|not_found|unreachable, method=advanced|direct}` +- Caffeine cache metrics; actuator endpoint `pgpkeys` to inspect/evict a cached address. + +## Milestones +1. **Skeleton** — parent POM, modules, CI. ✅ +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. +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: + `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 new file mode 100644 index 0000000..08d1b1f --- /dev/null +++ b/mailsender-demo/pom.xml @@ -0,0 +1,54 @@ + + + 4.0.0 + + + io.github.sneakybugmaster.mailsender + mailsender-parent + 0.1.0-SNAPSHOT + + + mailsender-demo + Demo app: looks up the recipient's WKD key and sends test e-mails + + + + io.github.sneakybugmaster.mailsender + mailsender-pgp-spring-boot-starter + + + org.springframework.boot + spring-boot-starter + + + + org.springframework.boot + spring-boot-starter-test + test + + + com.icegreen + greenmail-junit5 + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + ${spring-boot.version} + + + + repackage + + + + + + + diff --git a/mailsender-demo/src/main/java/io/github/sneakybugmaster/mailsender/demo/DemoApplication.java b/mailsender-demo/src/main/java/io/github/sneakybugmaster/mailsender/demo/DemoApplication.java new file mode 100644 index 0000000..97b987f --- /dev/null +++ b/mailsender-demo/src/main/java/io/github/sneakybugmaster/mailsender/demo/DemoApplication.java @@ -0,0 +1,14 @@ +package io.github.sneakybugmaster.mailsender.demo; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; + +@SpringBootApplication +@ConfigurationPropertiesScan +public class DemoApplication { + + public static void main(String[] args) { + SpringApplication.run(DemoApplication.class, args); + } +} diff --git a/mailsender-demo/src/main/java/io/github/sneakybugmaster/mailsender/demo/DemoProperties.java b/mailsender-demo/src/main/java/io/github/sneakybugmaster/mailsender/demo/DemoProperties.java new file mode 100644 index 0000000..7c57f92 --- /dev/null +++ b/mailsender-demo/src/main/java/io/github/sneakybugmaster/mailsender/demo/DemoProperties.java @@ -0,0 +1,16 @@ +package io.github.sneakybugmaster.mailsender.demo; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.DefaultValue; + +/** + * @param to recipient of the test mails + * @param from sender address; most SMTP providers require it to match the authenticated account + * @param count number of test mails to send + */ +@ConfigurationProperties("demo") +public record DemoProperties( + @DefaultValue("test@thinhha.pro") String to, + String from, + @DefaultValue("1") int count) { +} 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 new file mode 100644 index 0000000..7d561d0 --- /dev/null +++ b/mailsender-demo/src/main/java/io/github/sneakybugmaster/mailsender/demo/TestMailRunner.java @@ -0,0 +1,76 @@ +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/resources/application.yml b/mailsender-demo/src/main/resources/application.yml new file mode 100644 index 0000000..ead8c61 --- /dev/null +++ b/mailsender-demo/src/main/resources/application.yml @@ -0,0 +1,23 @@ +spring: + main: + web-application-type: none + mail: + host: ${SMTP_HOST:localhost} + port: ${SMTP_PORT:587} + username: ${SMTP_USERNAME:} + password: ${SMTP_PASSWORD:} + properties: + mail.smtp.auth: ${SMTP_AUTH:true} + mail.smtp.starttls.enable: ${SMTP_STARTTLS:true} + mail.smtp.starttls.required: ${SMTP_STARTTLS:true} + mail.smtp.connectiontimeout: 10000 + mail.smtp.timeout: 10000 + +demo: + to: ${DEMO_TO:test@thinhha.pro} + from: ${DEMO_FROM:${SMTP_USERNAME:}} + count: ${DEMO_COUNT:1} + +logging: + level: + io.github.sneakybugmaster.mailsender: DEBUG diff --git a/mailsender-demo/src/test/java/io/github/sneakybugmaster/mailsender/demo/TestMailRunnerTest.java b/mailsender-demo/src/test/java/io/github/sneakybugmaster/mailsender/demo/TestMailRunnerTest.java new file mode 100644 index 0000000..78f1354 --- /dev/null +++ b/mailsender-demo/src/test/java/io/github/sneakybugmaster/mailsender/demo/TestMailRunnerTest.java @@ -0,0 +1,59 @@ +package io.github.sneakybugmaster.mailsender.demo; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +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.GreenMailUtil; +import com.icegreen.greenmail.util.ServerSetupTest; + +import jakarta.mail.internet.MimeMessage; + +@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", + "demo.count=2", + // keep the test offline: no real WKD lookup + "mail.pgp.wkd.enabled=false" +}) +class TestMailRunnerTest { + + // Started before the Spring context, because the runner sends while the context starts. + static GreenMail greenMail; + + @BeforeAll + static void startSmtp() { + greenMail = new GreenMail(ServerSetupTest.SMTP.dynamicPort()); + greenMail.start(); + } + + @AfterAll + static void stopSmtp() { + greenMail.stop(); + } + + @DynamicPropertySource + static void smtp(DynamicPropertyRegistry registry) { + registry.add("spring.mail.host", () -> "127.0.0.1"); + registry.add("spring.mail.port", () -> greenMail.getSmtp().getPort()); + } + + @Test + void sendsTestMailsToConfiguredRecipient() throws Exception { + MimeMessage[] received = greenMail.getReceivedMessages(); + + 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"); + } +} diff --git a/mailsender-pgp-core/pom.xml b/mailsender-pgp-core/pom.xml new file mode 100644 index 0000000..b2bbbbf --- /dev/null +++ b/mailsender-pgp-core/pom.xml @@ -0,0 +1,50 @@ + + + 4.0.0 + + + io.github.sneakybugmaster.mailsender + mailsender-parent + 0.1.0-SNAPSHOT + + + mailsender-pgp-core + WKD key discovery, validation, OpenPGP encryption and PGP/MIME building (no Spring) + + + + org.pgpainless + pgpainless-core + + + org.pgpainless + wkd-java + + + com.github.ben-manes.caffeine + caffeine + + + org.slf4j + slf4j-api + + + + org.junit.jupiter + junit-jupiter + test + + + org.assertj + assertj-core + test + + + org.slf4j + slf4j-simple + test + + + diff --git a/mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/CachingKeyDiscovery.java b/mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/CachingKeyDiscovery.java new file mode 100644 index 0000000..b29ea5b --- /dev/null +++ b/mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/CachingKeyDiscovery.java @@ -0,0 +1,121 @@ +package io.github.sneakybugmaster.mailsender.pgp.discovery; + +import java.time.Clock; +import java.time.Duration; +import java.util.Date; +import java.util.Objects; + +import org.bouncycastle.openpgp.api.OpenPGPCertificate; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.github.benmanes.caffeine.cache.Expiry; +import com.github.benmanes.caffeine.cache.Ticker; + +/** + * Caches lookups per address with a TTL that depends on the outcome: + *

    + *
  • {@link DiscoveryResult.Found}: {@code ttl}, capped at the earliest certificate expiry
  • + *
  • {@link DiscoveryResult.NotFound}: {@code negativeTtl}
  • + *
  • {@link DiscoveryResult.Unreachable}: {@code errorTtl}
  • + *
+ */ +public final class CachingKeyDiscovery implements KeyDiscovery { + + public record Settings(Duration ttl, Duration negativeTtl, Duration errorTtl, long maxSize) { + public Settings { + requirePositive(ttl, "ttl"); + requirePositive(negativeTtl, "negativeTtl"); + requirePositive(errorTtl, "errorTtl"); + if (maxSize <= 0) { + throw new IllegalArgumentException("maxSize must be positive"); + } + } + + public static Settings defaults() { + return new Settings(Duration.ofHours(24), Duration.ofHours(1), Duration.ofMinutes(5), 10_000); + } + + private static void requirePositive(Duration duration, String name) { + Objects.requireNonNull(duration, name); + if (duration.isNegative() || duration.isZero()) { + throw new IllegalArgumentException(name + " must be positive"); + } + } + } + + private final KeyDiscovery delegate; + private final Cache cache; + + public CachingKeyDiscovery(KeyDiscovery delegate, Settings settings) { + this(delegate, settings, Ticker.systemTicker(), Clock.systemUTC()); + } + + CachingKeyDiscovery(KeyDiscovery delegate, Settings settings, Ticker ticker, Clock clock) { + this.delegate = Objects.requireNonNull(delegate, "delegate"); + this.cache = Caffeine.newBuilder() + .maximumSize(settings.maxSize()) + .ticker(ticker) + .expireAfter(new ResultExpiry(settings, clock)) + .build(); + } + + @Override + public DiscoveryResult discover(String email) { + return cache.get(StaticKeyDiscovery.normalize(email), delegate::discover); + } + + /** Drops the cached result for one address, e.g. after the recipient rotated their key. */ + public void evict(String email) { + cache.invalidate(StaticKeyDiscovery.normalize(email)); + } + + public void evictAll() { + cache.invalidateAll(); + } + + public long estimatedSize() { + return cache.estimatedSize(); + } + + private record ResultExpiry(Settings settings, Clock clock) implements Expiry { + + @Override + public long expireAfterCreate(String key, DiscoveryResult value, long currentTime) { + return ttlFor(value).toNanos(); + } + + @Override + public long expireAfterUpdate(String key, DiscoveryResult value, long currentTime, long currentDuration) { + return ttlFor(value).toNanos(); + } + + @Override + public long expireAfterRead(String key, DiscoveryResult value, long currentTime, long currentDuration) { + return currentDuration; + } + + private Duration ttlFor(DiscoveryResult value) { + return switch (value) { + case DiscoveryResult.Found found -> foundTtl(found); + case DiscoveryResult.NotFound ignored -> settings.negativeTtl(); + case DiscoveryResult.Unreachable ignored -> settings.errorTtl(); + }; + } + + private Duration foundTtl(DiscoveryResult.Found found) { + Duration ttl = settings.ttl(); + for (OpenPGPCertificate certificate : found.certificates()) { + Date expiration = certificate.getExpirationTime(); + if (expiration != null) { + Duration remaining = Duration.between(clock.instant(), expiration.toInstant()); + if (remaining.compareTo(ttl) < 0) { + ttl = remaining; + } + } + } + // Already expired: keep it only briefly; validation will reject it anyway. + return ttl.isNegative() || ttl.isZero() ? settings.errorTtl() : ttl; + } + } +} diff --git a/mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/Certificates.java b/mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/Certificates.java new file mode 100644 index 0000000..14e1a88 --- /dev/null +++ b/mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/Certificates.java @@ -0,0 +1,23 @@ +package io.github.sneakybugmaster.mailsender.pgp.discovery; + +import java.io.IOException; +import java.util.List; + +import org.bouncycastle.openpgp.api.OpenPGPCertificate; +import org.pgpainless.PGPainless; + +/** Parsing helpers for OpenPGP certificates (binary or ASCII-armored). */ +public final class Certificates { + + private Certificates() { + } + + /** + * Parses all certificates in {@code data}. Secret keys are rejected; only public certificates are accepted. + * + * @throws IOException if the data is not a valid OpenPGP certificate collection + */ + public static List parse(byte[] data) throws IOException { + return PGPainless.getInstance().readKey().parseCertificates(data); + } +} diff --git a/mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/CompositeKeyDiscovery.java b/mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/CompositeKeyDiscovery.java new file mode 100644 index 0000000..b7f6dee --- /dev/null +++ b/mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/CompositeKeyDiscovery.java @@ -0,0 +1,34 @@ +package io.github.sneakybugmaster.mailsender.pgp.discovery; + +import java.util.List; + +/** + * Asks each delegate in order and returns the first {@link DiscoveryResult.Found}. If none finds a key, the result + * is {@link DiscoveryResult.Unreachable} when any delegate was unreachable (so the outage stays visible), otherwise + * {@link DiscoveryResult.NotFound}. + */ +public final class CompositeKeyDiscovery implements KeyDiscovery { + + private final List delegates; + + public CompositeKeyDiscovery(List delegates) { + this.delegates = List.copyOf(delegates); + } + + @Override + public DiscoveryResult discover(String email) { + DiscoveryResult.Unreachable unreachable = null; + for (KeyDiscovery delegate : delegates) { + DiscoveryResult result = delegate.discover(email); + switch (result) { + case DiscoveryResult.Found found -> { + return found; + } + case DiscoveryResult.Unreachable u -> unreachable = unreachable == null ? u : unreachable; + case DiscoveryResult.NotFound ignored -> { + } + } + } + return unreachable != null ? unreachable : DiscoveryResult.notFound(); + } +} diff --git a/mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/DiscoveryResult.java b/mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/DiscoveryResult.java new file mode 100644 index 0000000..b822509 --- /dev/null +++ b/mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/DiscoveryResult.java @@ -0,0 +1,36 @@ +package io.github.sneakybugmaster.mailsender.pgp.discovery; + +import java.util.List; +import java.util.Objects; + +import org.bouncycastle.openpgp.api.OpenPGPCertificate; + +/** Outcome of a {@link KeyDiscovery} lookup. */ +public sealed interface DiscoveryResult { + + /** One or more certificates were found. They still need validation before use. */ + record Found(List certificates, DiscoverySource source) implements DiscoveryResult { + public Found { + certificates = List.copyOf(certificates); + if (certificates.isEmpty()) { + throw new IllegalArgumentException("Found requires at least one certificate"); + } + Objects.requireNonNull(source, "source"); + } + } + + /** The lookup completed and authoritatively found no key. */ + record NotFound() implements DiscoveryResult { + } + + /** The lookup could not complete (timeout, 5xx, TLS error, malformed response, ...). */ + record Unreachable(String reason) implements DiscoveryResult { + public Unreachable { + Objects.requireNonNull(reason, "reason"); + } + } + + static DiscoveryResult notFound() { + return new NotFound(); + } +} diff --git a/mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/DiscoverySource.java b/mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/DiscoverySource.java new file mode 100644 index 0000000..aa0aa71 --- /dev/null +++ b/mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/DiscoverySource.java @@ -0,0 +1,11 @@ +package io.github.sneakybugmaster.mailsender.pgp.discovery; + +/** Where a certificate came from. */ +public enum DiscoverySource { + /** Pinned by the application via configuration. */ + STATIC, + /** WKD advanced method ({@code openpgpkey.}). */ + WKD_ADVANCED, + /** WKD direct method ({@code /.well-known/openpgpkey}). */ + WKD_DIRECT +} diff --git a/mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/HostResolver.java b/mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/HostResolver.java new file mode 100644 index 0000000..50d368e --- /dev/null +++ b/mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/HostResolver.java @@ -0,0 +1,22 @@ +package io.github.sneakybugmaster.mailsender.pgp.discovery; + +import java.net.InetAddress; +import java.net.UnknownHostException; + +/** Decides whether a host name exists in DNS. Used to pick the WKD method. */ +@FunctionalInterface +public interface HostResolver { + + boolean resolves(String host); + + /** Resolves through the JVM's resolver ({@link InetAddress#getAllByName}). */ + static HostResolver system() { + return host -> { + try { + return InetAddress.getAllByName(host).length > 0; + } catch (UnknownHostException e) { + return false; + } + }; + } +} diff --git a/mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/HttpWkdFetcher.java b/mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/HttpWkdFetcher.java new file mode 100644 index 0000000..f1e95fb --- /dev/null +++ b/mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/HttpWkdFetcher.java @@ -0,0 +1,64 @@ +package io.github.sneakybugmaster.mailsender.pgp.discovery; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InterruptedIOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.Objects; + +/** + * {@link WkdFetcher} on the JDK {@link HttpClient}: no redirects, bounded timeouts and a maximum body size. + * Uses the JVM default proxy selector. + */ +public final class HttpWkdFetcher implements WkdFetcher { + + private final HttpClient client; + private final Duration readTimeout; + private final long maxResponseSize; + + public HttpWkdFetcher(Duration connectTimeout, Duration readTimeout, long maxResponseSize) { + this(HttpClient.newBuilder() + .connectTimeout(connectTimeout) + .followRedirects(HttpClient.Redirect.NEVER) + .build(), readTimeout, maxResponseSize); + } + + HttpWkdFetcher(HttpClient client, Duration readTimeout, long maxResponseSize) { + this.client = Objects.requireNonNull(client, "client"); + this.readTimeout = Objects.requireNonNull(readTimeout, "readTimeout"); + if (maxResponseSize <= 0) { + throw new IllegalArgumentException("maxResponseSize must be positive"); + } + this.maxResponseSize = maxResponseSize; + } + + @Override + public Response fetch(URI uri) throws IOException { + HttpRequest request = HttpRequest.newBuilder(uri) + .timeout(readTimeout) + .header("Accept", "application/octet-stream") + .GET() + .build(); + HttpResponse response; + try { + response = client.send(request, HttpResponse.BodyHandlers.ofInputStream()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new InterruptedIOException("WKD request interrupted: " + uri); + } + try (InputStream body = response.body()) { + if (response.statusCode() != 200) { + return new Response(response.statusCode(), null); + } + byte[] bytes = body.readNBytes(Math.toIntExact(Math.min(maxResponseSize + 1, Integer.MAX_VALUE))); + if (bytes.length > maxResponseSize) { + throw new IOException("WKD response from " + uri.getHost() + " exceeds " + maxResponseSize + " bytes"); + } + return new Response(200, bytes); + } + } +} diff --git a/mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/KeyDiscovery.java b/mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/KeyDiscovery.java new file mode 100644 index 0000000..5909517 --- /dev/null +++ b/mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/KeyDiscovery.java @@ -0,0 +1,19 @@ +package io.github.sneakybugmaster.mailsender.pgp.discovery; + +/** + * Finds the OpenPGP certificate(s) published for an e-mail address. + *

+ * Implementations never throw for "no key" or network problems; those are expressed as + * {@link DiscoveryResult.NotFound} and {@link DiscoveryResult.Unreachable} so the policy layer can + * decide what to do. Certificates returned in {@link DiscoveryResult.Found} are not validated + * yet (expiry, revocation, encryption subkey, matching User ID). + */ +@FunctionalInterface +public interface KeyDiscovery { + + /** + * @param email a bare address such as {@code alice@example.org} + * @throws IllegalArgumentException if {@code email} is not a valid address + */ + DiscoveryResult discover(String email); +} diff --git a/mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/StaticKeyDiscovery.java b/mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/StaticKeyDiscovery.java new file mode 100644 index 0000000..eb84c35 --- /dev/null +++ b/mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/StaticKeyDiscovery.java @@ -0,0 +1,34 @@ +package io.github.sneakybugmaster.mailsender.pgp.discovery; + +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import org.bouncycastle.openpgp.api.OpenPGPCertificate; + +/** Certificates pinned by the application. Addresses are matched case-insensitively. */ +public final class StaticKeyDiscovery implements KeyDiscovery { + + private final Map> certificates; + + public StaticKeyDiscovery(Map> certificates) { + Map> normalized = new HashMap<>(); + certificates.forEach((email, certs) -> { + if (!certs.isEmpty()) { + normalized.put(normalize(email), List.copyOf(certs)); + } + }); + this.certificates = Map.copyOf(normalized); + } + + @Override + public DiscoveryResult discover(String email) { + List certs = certificates.get(normalize(email)); + return certs == null ? DiscoveryResult.notFound() : new DiscoveryResult.Found(certs, DiscoverySource.STATIC); + } + + static String normalize(String email) { + return email.strip().toLowerCase(Locale.ROOT); + } +} diff --git a/mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/WkdFallback.java b/mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/WkdFallback.java new file mode 100644 index 0000000..befffe2 --- /dev/null +++ b/mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/WkdFallback.java @@ -0,0 +1,15 @@ +package io.github.sneakybugmaster.mailsender.pgp.discovery; + +/** When to fall back from the WKD advanced method to the direct method. */ +public enum WkdFallback { + /** + * Only when the {@code openpgpkey.} host does not resolve, as the WKD draft specifies. + * A 404 from an existing advanced host is final. + */ + ON_DNS_FAILURE, + /** + * Whenever the advanced method does not return a key. Finds more keys on domains with wildcard DNS + * whose advanced host answers 404 or serves a mismatching TLS certificate. + */ + ON_ANY_FAILURE +} diff --git a/mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/WkdFetcher.java b/mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/WkdFetcher.java new file mode 100644 index 0000000..41f216d --- /dev/null +++ b/mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/WkdFetcher.java @@ -0,0 +1,21 @@ +package io.github.sneakybugmaster.mailsender.pgp.discovery; + +import java.io.IOException; +import java.net.URI; + +/** Performs the HTTP GET of a WKD URI. */ +@FunctionalInterface +public interface WkdFetcher { + + /** + * @return the HTTP status and body; the body is empty for non-200 responses + * @throws IOException on connection, TLS or timeout errors, or when the body exceeds the size limit + */ + Response fetch(URI uri) throws IOException; + + record Response(int statusCode, byte[] body) { + public Response { + body = body == null ? new byte[0] : body; + } + } +} diff --git a/mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/WkdKeyDiscovery.java b/mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/WkdKeyDiscovery.java new file mode 100644 index 0000000..62042cd --- /dev/null +++ b/mailsender-pgp-core/src/main/java/io/github/sneakybugmaster/mailsender/pgp/discovery/WkdKeyDiscovery.java @@ -0,0 +1,96 @@ +package io.github.sneakybugmaster.mailsender.pgp.discovery; + +import java.io.IOException; +import java.net.URI; +import java.util.List; +import java.util.Objects; + +import org.bouncycastle.openpgp.api.OpenPGPCertificate; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import pgp.wkd.WKDAddress; +import pgp.wkd.exception.MalformedUserIdException; + +/** + * Web Key Directory lookup: tries the advanced method, then the direct method according to {@link WkdFallback}. + * Hashing and URL building are delegated to {@link WKDAddress} (lowercased local part, SHA-1, z-base-32, + * {@code ?l=}). + */ +public final class WkdKeyDiscovery implements KeyDiscovery { + + private static final Logger log = LoggerFactory.getLogger(WkdKeyDiscovery.class); + + private final WkdFetcher fetcher; + private final HostResolver hostResolver; + private final WkdFallback fallback; + + public WkdKeyDiscovery(WkdFetcher fetcher, HostResolver hostResolver, WkdFallback fallback) { + this.fetcher = Objects.requireNonNull(fetcher, "fetcher"); + this.hostResolver = Objects.requireNonNull(hostResolver, "hostResolver"); + this.fallback = Objects.requireNonNull(fallback, "fallback"); + } + + @Override + public DiscoveryResult discover(String email) { + WKDAddress address = toWkdAddress(email); + + URI advanced = address.getAdvancedMethodURI(); + if (!hostResolver.resolves(advanced.getHost())) { + log.debug("WKD advanced host {} does not resolve, using direct method", advanced.getHost()); + return lookup(address.getDirectMethodURI(), DiscoverySource.WKD_DIRECT); + } + + DiscoveryResult result = lookup(advanced, DiscoverySource.WKD_ADVANCED); + if (result instanceof DiscoveryResult.Found || fallback == WkdFallback.ON_DNS_FAILURE) { + return result; + } + log.debug("WKD advanced method gave {} for {}, trying direct method", result, address.getEmail()); + return lookup(address.getDirectMethodURI(), DiscoverySource.WKD_DIRECT); + } + + private DiscoveryResult lookup(URI uri, DiscoverySource source) { + WkdFetcher.Response response; + try { + response = fetcher.fetch(uri); + } catch (IOException e) { + log.debug("WKD fetch {} failed", uri, e); + return new DiscoveryResult.Unreachable(source + ": " + describe(e)); + } + + if (response.statusCode() == 404) { + return DiscoveryResult.notFound(); + } + if (response.statusCode() != 200) { + return new DiscoveryResult.Unreachable(source + ": HTTP " + response.statusCode()); + } + if (response.body().length == 0) { + return DiscoveryResult.notFound(); + } + + List certificates; + try { + certificates = Certificates.parse(response.body()); + } catch (IOException | RuntimeException e) { + log.debug("WKD response from {} is not a valid certificate", uri, e); + return new DiscoveryResult.Unreachable(source + ": malformed certificate"); + } + // The parser skips data it does not recognise, so a non-empty body without certificates is malformed too. + return certificates.isEmpty() + ? new DiscoveryResult.Unreachable(source + ": malformed certificate") + : new DiscoveryResult.Found(certificates, source); + } + + private static WKDAddress toWkdAddress(String email) { + try { + return WKDAddress.fromEmail(Objects.requireNonNull(email, "email").strip()); + } catch (MalformedUserIdException e) { + throw new IllegalArgumentException("Not a valid e-mail address: " + email, e); + } + } + + private static String describe(IOException e) { + String message = e.getMessage(); + return message == null ? e.getClass().getSimpleName() : e.getClass().getSimpleName() + " " + message; + } +} diff --git a/mailsender-pgp-core/src/test/java/io/github/sneakybugmaster/mailsender/pgp/discovery/CachingKeyDiscoveryTest.java b/mailsender-pgp-core/src/test/java/io/github/sneakybugmaster/mailsender/pgp/discovery/CachingKeyDiscoveryTest.java new file mode 100644 index 0000000..2b466e4 --- /dev/null +++ b/mailsender-pgp-core/src/test/java/io/github/sneakybugmaster/mailsender/pgp/discovery/CachingKeyDiscoveryTest.java @@ -0,0 +1,109 @@ +package io.github.sneakybugmaster.mailsender.pgp.discovery; + +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 java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; + +import org.bouncycastle.openpgp.api.OpenPGPCertificate; +import org.junit.jupiter.api.Test; + +class CachingKeyDiscoveryTest { + + private static final CachingKeyDiscovery.Settings SETTINGS = new CachingKeyDiscovery.Settings( + Duration.ofHours(24), Duration.ofHours(1), Duration.ofMinutes(5), 100); + + private final AtomicLong nanos = new AtomicLong(); + private final AtomicInteger calls = new AtomicInteger(); + private final Instant now = Instant.now(); + + @Test + void cachesFoundUntilTtl() { + CachingKeyDiscovery cache = cache(found(TestKeys.certificate("Alice "))); + + cache.discover("alice@example.org"); + advance(Duration.ofHours(23)); + cache.discover("ALICE@example.org"); + assertThat(calls).hasValue(1); + + advance(Duration.ofHours(2)); + cache.discover("alice@example.org"); + assertThat(calls).hasValue(2); + } + + @Test + void cachesNotFoundForNegativeTtl() { + CachingKeyDiscovery cache = cache(DiscoveryResult.notFound()); + + cache.discover("bob@example.org"); + advance(Duration.ofMinutes(59)); + cache.discover("bob@example.org"); + assertThat(calls).hasValue(1); + + advance(Duration.ofMinutes(2)); + cache.discover("bob@example.org"); + assertThat(calls).hasValue(2); + } + + @Test + void cachesUnreachableForErrorTtl() { + CachingKeyDiscovery cache = cache(new DiscoveryResult.Unreachable("timeout")); + + cache.discover("bob@example.org"); + advance(Duration.ofMinutes(4)); + cache.discover("bob@example.org"); + assertThat(calls).hasValue(1); + + advance(Duration.ofMinutes(2)); + cache.discover("bob@example.org"); + assertThat(calls).hasValue(2); + } + + @Test + void capsTtlAtCertificateExpiry() { + Date expiry = Date.from(now.plus(Duration.ofHours(2))); + CachingKeyDiscovery cache = cache(found(TestKeys.certificateExpiringAt("Alice ", expiry))); + + cache.discover("alice@example.org"); + advance(Duration.ofMinutes(119)); + cache.discover("alice@example.org"); + assertThat(calls).hasValue(1); + + advance(Duration.ofMinutes(2)); + cache.discover("alice@example.org"); + assertThat(calls).hasValue(2); + } + + @Test + void evictForcesNewLookup() { + CachingKeyDiscovery cache = cache(DiscoveryResult.notFound()); + + cache.discover("bob@example.org"); + cache.evict("Bob@Example.org"); + cache.discover("bob@example.org"); + + assertThat(calls).hasValue(2); + } + + private CachingKeyDiscovery cache(DiscoveryResult result) { + KeyDiscovery delegate = email -> { + calls.incrementAndGet(); + return result; + }; + return new CachingKeyDiscovery(delegate, SETTINGS, nanos::get, Clock.fixed(now, ZoneOffset.UTC)); + } + + private void advance(Duration duration) { + nanos.addAndGet(duration.toNanos()); + } + + private static DiscoveryResult found(OpenPGPCertificate certificate) { + return new DiscoveryResult.Found(List.of(certificate), DiscoverySource.WKD_ADVANCED); + } +} diff --git a/mailsender-pgp-core/src/test/java/io/github/sneakybugmaster/mailsender/pgp/discovery/CompositeKeyDiscoveryTest.java b/mailsender-pgp-core/src/test/java/io/github/sneakybugmaster/mailsender/pgp/discovery/CompositeKeyDiscoveryTest.java new file mode 100644 index 0000000..6172fab --- /dev/null +++ b/mailsender-pgp-core/src/test/java/io/github/sneakybugmaster/mailsender/pgp/discovery/CompositeKeyDiscoveryTest.java @@ -0,0 +1,44 @@ +package io.github.sneakybugmaster.mailsender.pgp.discovery; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Map; + +import org.bouncycastle.openpgp.api.OpenPGPCertificate; +import org.junit.jupiter.api.Test; + +class CompositeKeyDiscoveryTest { + + private static final OpenPGPCertificate BOB = TestKeys.certificate("Bob "); + + private final StaticKeyDiscovery pinned = new StaticKeyDiscovery(Map.of("Bob@Legacy.org", List.of(BOB))); + + @Test + void staticKeyWinsWithoutAskingLaterDelegates() { + KeyDiscovery wkd = email -> { + throw new AssertionError("WKD must not be queried"); + }; + + DiscoveryResult result = new CompositeKeyDiscovery(List.of(pinned, wkd)).discover("bob@legacy.org"); + + assertThat(result).isInstanceOfSatisfying(DiscoveryResult.Found.class, + found -> assertThat(found.source()).isEqualTo(DiscoverySource.STATIC)); + } + + @Test + void reportsUnreachableWhenNoDelegateFoundAKey() { + KeyDiscovery wkd = email -> new DiscoveryResult.Unreachable("timeout"); + + assertThat(new CompositeKeyDiscovery(List.of(pinned, wkd)).discover("carol@example.org")) + .isInstanceOf(DiscoveryResult.Unreachable.class); + } + + @Test + void reportsNotFoundWhenAllDelegatesAnsweredNotFound() { + KeyDiscovery wkd = email -> DiscoveryResult.notFound(); + + assertThat(new CompositeKeyDiscovery(List.of(pinned, wkd)).discover("carol@example.org")) + .isInstanceOf(DiscoveryResult.NotFound.class); + } +} diff --git a/mailsender-pgp-core/src/test/java/io/github/sneakybugmaster/mailsender/pgp/discovery/HttpWkdFetcherTest.java b/mailsender-pgp-core/src/test/java/io/github/sneakybugmaster/mailsender/pgp/discovery/HttpWkdFetcherTest.java new file mode 100644 index 0000000..a9519cd --- /dev/null +++ b/mailsender-pgp-core/src/test/java/io/github/sneakybugmaster/mailsender/pgp/discovery/HttpWkdFetcherTest.java @@ -0,0 +1,85 @@ +package io.github.sneakybugmaster.mailsender.pgp.discovery; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIOException; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.URI; +import java.time.Duration; +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.sun.net.httpserver.HttpServer; + +class HttpWkdFetcherTest { + + private HttpServer server; + private final AtomicReference lastQuery = new AtomicReference<>(); + private final HttpWkdFetcher fetcher = new HttpWkdFetcher(Duration.ofSeconds(2), Duration.ofSeconds(2), 1024); + + @BeforeEach + void start() throws IOException { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/key", exchange -> { + lastQuery.set(exchange.getRequestURI().getRawQuery()); + respond(exchange, 200, new byte[] {1, 2, 3}); + }); + server.createContext("/missing", exchange -> respond(exchange, 404, new byte[0])); + server.createContext("/big", exchange -> respond(exchange, 200, new byte[2048])); + server.createContext("/redirect", exchange -> { + exchange.getResponseHeaders().add("Location", "/key"); + respond(exchange, 302, new byte[0]); + }); + server.start(); + } + + @AfterEach + void stop() { + server.stop(0); + } + + @Test + void returnsBodyOn200() throws IOException { + WkdFetcher.Response response = fetcher.fetch(uri("/key?l=alice")); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(response.body()).containsExactly(1, 2, 3); + assertThat(lastQuery.get()).isEqualTo("l=alice"); + } + + @Test + void returnsStatusWithoutBodyOnNon200() throws IOException { + WkdFetcher.Response response = fetcher.fetch(uri("/missing")); + + assertThat(response.statusCode()).isEqualTo(404); + assertThat(response.body()).isEmpty(); + } + + @Test + void doesNotFollowRedirects() throws IOException { + assertThat(fetcher.fetch(uri("/redirect")).statusCode()).isEqualTo(302); + assertThat(lastQuery.get()).isNull(); + } + + @Test + void rejectsOversizedBody() { + assertThatIOException().isThrownBy(() -> fetcher.fetch(uri("/big"))).withMessageContaining("exceeds 1024"); + } + + private URI uri(String pathAndQuery) { + return URI.create("http://127.0.0.1:" + server.getAddress().getPort() + pathAndQuery); + } + + private static void respond(com.sun.net.httpserver.HttpExchange exchange, int status, byte[] body) + throws IOException { + exchange.sendResponseHeaders(status, body.length == 0 ? -1 : body.length); + try (OutputStream out = exchange.getResponseBody()) { + out.write(body); + } + } +} diff --git a/mailsender-pgp-core/src/test/java/io/github/sneakybugmaster/mailsender/pgp/discovery/TestKeys.java b/mailsender-pgp-core/src/test/java/io/github/sneakybugmaster/mailsender/pgp/discovery/TestKeys.java new file mode 100644 index 0000000..4748dc5 --- /dev/null +++ b/mailsender-pgp-core/src/test/java/io/github/sneakybugmaster/mailsender/pgp/discovery/TestKeys.java @@ -0,0 +1,37 @@ +package io.github.sneakybugmaster.mailsender.pgp.discovery; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.Date; + +import org.bouncycastle.openpgp.api.OpenPGPCertificate; +import org.bouncycastle.openpgp.api.OpenPGPKey; +import org.pgpainless.PGPainless; +import org.pgpainless.key.protection.SecretKeyRingProtector; + +final class TestKeys { + + private TestKeys() { + } + + static OpenPGPCertificate certificate(String userId) { + return PGPainless.getInstance().generateKey().modernKeyRing(userId).toCertificate(); + } + + static OpenPGPCertificate certificateExpiringAt(String userId, Date expiration) { + PGPainless api = PGPainless.getInstance(); + OpenPGPKey key = api.generateKey().modernKeyRing(userId); + return api.modify(key) + .setExpirationDate(expiration, SecretKeyRingProtector.unprotectedKeys()) + .done() + .toCertificate(); + } + + static byte[] encoded(OpenPGPCertificate certificate) { + try { + return certificate.getEncoded(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } +} diff --git a/mailsender-pgp-core/src/test/java/io/github/sneakybugmaster/mailsender/pgp/discovery/WkdAddressTest.java b/mailsender-pgp-core/src/test/java/io/github/sneakybugmaster/mailsender/pgp/discovery/WkdAddressTest.java new file mode 100644 index 0000000..3265517 --- /dev/null +++ b/mailsender-pgp-core/src/test/java/io/github/sneakybugmaster/mailsender/pgp/discovery/WkdAddressTest.java @@ -0,0 +1,21 @@ +package io.github.sneakybugmaster.mailsender.pgp.discovery; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +import pgp.wkd.WKDAddress; + +/** Pins the behaviour we rely on from wkd-java against the test vector in the WKD draft. */ +class WkdAddressTest { + + @Test + void buildsDraftTestVectorUris() throws Exception { + WKDAddress address = WKDAddress.fromEmail("Joe.Doe@Example.ORG"); + + assertThat(address.getAdvancedMethodURI()).hasToString( + "https://openpgpkey.example.org/.well-known/openpgpkey/example.org/hu/iy9q119eutrkn8s1mk4r39qejnbu3n5q?l=Joe.Doe"); + assertThat(address.getDirectMethodURI()).hasToString( + "https://example.org/.well-known/openpgpkey/hu/iy9q119eutrkn8s1mk4r39qejnbu3n5q?l=Joe.Doe"); + } +} diff --git a/mailsender-pgp-core/src/test/java/io/github/sneakybugmaster/mailsender/pgp/discovery/WkdKeyDiscoveryTest.java b/mailsender-pgp-core/src/test/java/io/github/sneakybugmaster/mailsender/pgp/discovery/WkdKeyDiscoveryTest.java new file mode 100644 index 0000000..953600e --- /dev/null +++ b/mailsender-pgp-core/src/test/java/io/github/sneakybugmaster/mailsender/pgp/discovery/WkdKeyDiscoveryTest.java @@ -0,0 +1,160 @@ +package io.github.sneakybugmaster.mailsender.pgp.discovery; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +import java.io.IOException; +import java.net.SocketTimeoutException; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.bouncycastle.openpgp.api.OpenPGPCertificate; +import org.junit.jupiter.api.Test; + +class WkdKeyDiscoveryTest { + + private static final String EMAIL = "alice@example.org"; + private static final OpenPGPCertificate ALICE = TestKeys.certificate("Alice "); + + private final FakeFetcher fetcher = new FakeFetcher(); + + @Test + void usesAdvancedMethodWhenItsHostResolves() { + fetcher.respond("openpgpkey.example.org", 200, TestKeys.encoded(ALICE)); + + DiscoveryResult result = discovery(true, WkdFallback.ON_DNS_FAILURE).discover(EMAIL); + + assertThat(result).isInstanceOfSatisfying(DiscoveryResult.Found.class, found -> { + assertThat(found.source()).isEqualTo(DiscoverySource.WKD_ADVANCED); + assertThat(found.certificates()).singleElement() + .extracting(OpenPGPCertificate::getKeyIdentifier).isEqualTo(ALICE.getKeyIdentifier()); + }); + assertThat(fetcher.requestedHosts()).containsExactly("openpgpkey.example.org"); + } + + @Test + void requestsLowercasedHashAndOriginalLocalPart() { + fetcher.respond("openpgpkey.example.org", 404, null); + + discovery(true, WkdFallback.ON_DNS_FAILURE).discover("Joe.Doe@Example.ORG"); + + assertThat(fetcher.requested).singleElement().hasToString( + "https://openpgpkey.example.org/.well-known/openpgpkey/example.org/hu/iy9q119eutrkn8s1mk4r39qejnbu3n5q?l=Joe.Doe"); + } + + @Test + void fallsBackToDirectMethodWhenAdvancedHostDoesNotResolve() { + fetcher.respond("example.org", 200, TestKeys.encoded(ALICE)); + + DiscoveryResult result = discovery(false, WkdFallback.ON_DNS_FAILURE).discover(EMAIL); + + assertThat(result).isInstanceOfSatisfying(DiscoveryResult.Found.class, + found -> assertThat(found.source()).isEqualTo(DiscoverySource.WKD_DIRECT)); + assertThat(fetcher.requestedHosts()).containsExactly("example.org"); + } + + @Test + void advanced404IsFinalWhenFallingBackOnlyOnDnsFailure() { + fetcher.respond("openpgpkey.example.org", 404, null); + fetcher.respond("example.org", 200, TestKeys.encoded(ALICE)); + + DiscoveryResult result = discovery(true, WkdFallback.ON_DNS_FAILURE).discover(EMAIL); + + assertThat(result).isInstanceOf(DiscoveryResult.NotFound.class); + assertThat(fetcher.requestedHosts()).containsExactly("openpgpkey.example.org"); + } + + @Test + void advanced404FallsBackWhenConfiguredForAnyFailure() { + fetcher.respond("openpgpkey.example.org", 404, null); + fetcher.respond("example.org", 200, TestKeys.encoded(ALICE)); + + DiscoveryResult result = discovery(true, WkdFallback.ON_ANY_FAILURE).discover(EMAIL); + + assertThat(result).isInstanceOfSatisfying(DiscoveryResult.Found.class, + found -> assertThat(found.source()).isEqualTo(DiscoverySource.WKD_DIRECT)); + assertThat(fetcher.requestedHosts()).containsExactly("openpgpkey.example.org", "example.org"); + } + + @Test + void ioErrorIsUnreachable() { + fetcher.fail("openpgpkey.example.org", new SocketTimeoutException("read timed out")); + + DiscoveryResult result = discovery(true, WkdFallback.ON_DNS_FAILURE).discover(EMAIL); + + assertThat(result).isInstanceOfSatisfying(DiscoveryResult.Unreachable.class, + u -> assertThat(u.reason()).contains("WKD_ADVANCED", "read timed out")); + } + + @Test + void serverErrorAndRedirectAreUnreachable() { + fetcher.respond("example.org", 503, null); + assertThat(discovery(false, WkdFallback.ON_DNS_FAILURE).discover(EMAIL)) + .isInstanceOfSatisfying(DiscoveryResult.Unreachable.class, + u -> assertThat(u.reason()).contains("HTTP 503")); + + fetcher.respond("example.org", 302, null); + assertThat(discovery(false, WkdFallback.ON_DNS_FAILURE).discover(EMAIL)) + .isInstanceOf(DiscoveryResult.Unreachable.class); + } + + @Test + void malformedBodyIsUnreachable() { + fetcher.respond("example.org", 200, "not a key".getBytes(StandardCharsets.UTF_8)); + + assertThat(discovery(false, WkdFallback.ON_DNS_FAILURE).discover(EMAIL)) + .isInstanceOfSatisfying(DiscoveryResult.Unreachable.class, + u -> assertThat(u.reason()).contains("malformed")); + } + + @Test + void emptyBodyIsNotFound() { + fetcher.respond("example.org", 200, new byte[0]); + + assertThat(discovery(false, WkdFallback.ON_DNS_FAILURE).discover(EMAIL)) + .isInstanceOf(DiscoveryResult.NotFound.class); + } + + @Test + void rejectsInvalidAddress() { + assertThatIllegalArgumentException() + .isThrownBy(() -> discovery(true, WkdFallback.ON_DNS_FAILURE).discover("not-an-address")); + } + + private WkdKeyDiscovery discovery(boolean advancedHostResolves, WkdFallback fallback) { + HostResolver resolver = host -> !host.startsWith("openpgpkey.") || advancedHostResolves; + return new WkdKeyDiscovery(fetcher, resolver, fallback); + } + + private static final class FakeFetcher implements WkdFetcher { + private final Map responses = new HashMap<>(); + private final Map failures = new HashMap<>(); + final List requested = new ArrayList<>(); + + void respond(String host, int status, byte[] body) { + responses.put(host, new Response(status, body)); + } + + void fail(String host, IOException e) { + failures.put(host, e); + } + + List requestedHosts() { + return requested.stream().map(URI::getHost).toList(); + } + + @Override + public Response fetch(URI uri) throws IOException { + requested.add(uri); + IOException failure = failures.get(uri.getHost()); + if (failure != null) { + throw failure; + } + return responses.getOrDefault(uri.getHost(), new Response(404, null)); + } + } +} diff --git a/mailsender-pgp-spring-boot-autoconfigure/pom.xml b/mailsender-pgp-spring-boot-autoconfigure/pom.xml new file mode 100644 index 0000000..853366d --- /dev/null +++ b/mailsender-pgp-spring-boot-autoconfigure/pom.xml @@ -0,0 +1,54 @@ + + + 4.0.0 + + + io.github.sneakybugmaster.mailsender + mailsender-parent + 0.1.0-SNAPSHOT + + + mailsender-pgp-spring-boot-autoconfigure + + + + io.github.sneakybugmaster.mailsender + mailsender-pgp-core + + + org.springframework.boot + spring-boot-autoconfigure + + + org.springframework.boot + spring-boot-configuration-processor + true + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.springframework.boot + spring-boot-configuration-processor + ${spring-boot.version} + + + + + + + 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/io/github/sneakybugmaster/mailsender/pgp/autoconfigure/PgpMailAutoConfiguration.java new file mode 100644 index 0000000..cfb66f7 --- /dev/null +++ b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/io/github/sneakybugmaster/mailsender/pgp/autoconfigure/PgpMailAutoConfiguration.java @@ -0,0 +1,89 @@ +package io.github.sneakybugmaster.mailsender.pgp.autoconfigure; + +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.bouncycastle.openpgp.api.OpenPGPCertificate; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +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 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; + +/** + * 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. + */ +@AutoConfiguration +@EnableConfigurationProperties(PgpMailProperties.class) +@ConditionalOnProperty(prefix = "mail.pgp", name = "enabled", havingValue = "true", matchIfMissing = true) +public class PgpMailAutoConfiguration { + + @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); + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = "mail.pgp.wkd", name = "enabled", havingValue = "true", matchIfMissing = true) + WkdFetcher pgpWkdFetcher(PgpMailProperties properties) { + PgpMailProperties.Wkd wkd = properties.wkd(); + return new HttpWkdFetcher(wkd.connectTimeout(), wkd.readTimeout(), wkd.maxResponseSize().toBytes()); + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = "mail.pgp.wkd", name = "enabled", havingValue = "true", matchIfMissing = true) + CachingKeyDiscovery pgpCachingWkdDiscovery(PgpMailProperties properties, WkdFetcher fetcher, + ObjectProvider hostResolver) { + WkdKeyDiscovery wkd = new WkdKeyDiscovery(fetcher, hostResolver.getIfAvailable(HostResolver::system), + properties.wkd().fallback()); + PgpMailProperties.Cache cache = properties.cache(); + return new CachingKeyDiscovery(wkd, new CachingKeyDiscovery.Settings( + cache.ttl(), cache.negativeTtl(), cache.errorTtl(), cache.maxSize())); + } + + @Bean + @Primary + @ConditionalOnMissingBean(name = "pgpKeyDiscovery") + KeyDiscovery pgpKeyDiscovery(StaticKeyDiscovery staticKeys, ObjectProvider wkd) { + List chain = new ArrayList<>(); + chain.add(staticKeys); + wkd.ifAvailable(chain::add); + return new CompositeKeyDiscovery(chain); + } + + private static List read(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); + } + return certificates; + } catch (IOException e) { + throw new UncheckedIOException("mail.pgp.static-keys[" + email + "]: cannot read " + resource, e); + } + } +} 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/io/github/sneakybugmaster/mailsender/pgp/autoconfigure/PgpMailProperties.java new file mode 100644 index 0000000..a7119fa --- /dev/null +++ b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/io/github/sneakybugmaster/mailsender/pgp/autoconfigure/PgpMailProperties.java @@ -0,0 +1,52 @@ +package io.github.sneakybugmaster.mailsender.pgp.autoconfigure; + +import java.time.Duration; +import java.util.Map; + +import org.springframework.boot.context.properties.ConfigurationProperties; +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; + +/** + * {@code mail.pgp.*} configuration. + *

+ * 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 + */ +@ConfigurationProperties("mail.pgp") +public record PgpMailProperties( + @DefaultValue("true") boolean enabled, + @DefaultValue Wkd wkd, + @DefaultValue Cache cache, + Map staticKeys) { + + public PgpMailProperties { + staticKeys = staticKeys == null ? Map.of() : Map.copyOf(staticKeys); + } + + public record Wkd( + @DefaultValue("true") boolean enabled, + @DefaultValue("ON_DNS_FAILURE") WkdFallback fallback, + @DefaultValue("3s") Duration connectTimeout, + @DefaultValue("5s") Duration readTimeout, + @DefaultValue("256KB") DataSize maxResponseSize) { + } + + /** + * @param ttl lifetime of a found key (capped at the key's expiry) + * @param negativeTtl lifetime of "no key published" + * @param errorTtl lifetime of a failed lookup (timeout, 5xx, malformed response) + */ + public record Cache( + @DefaultValue("24h") Duration ttl, + @DefaultValue("1h") Duration negativeTtl, + @DefaultValue("5m") Duration errorTtl, + @DefaultValue("10000") long maxSize) { + } +} 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 new file mode 100644 index 0000000..60e0bac --- /dev/null +++ b/mailsender-pgp-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +io.github.sneakybugmaster.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/io/github/sneakybugmaster/mailsender/pgp/autoconfigure/PgpMailAutoConfigurationTest.java new file mode 100644 index 0000000..731cbfd --- /dev/null +++ b/mailsender-pgp-spring-boot-autoconfigure/src/test/java/io/github/sneakybugmaster/mailsender/pgp/autoconfigure/PgpMailAutoConfigurationTest.java @@ -0,0 +1,75 @@ +package io.github.sneakybugmaster.mailsender.pgp.autoconfigure; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; + +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.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; + +class PgpMailAutoConfigurationTest { + + private final ApplicationContextRunner runner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(PgpMailAutoConfiguration.class)); + + @TempDir + Path tempDir; + + @Test + void createsDiscoveryChainWithDefaults() { + runner.run(context -> { + assertThat(context).hasSingleBean(WkdFetcher.class).hasSingleBean(CachingKeyDiscovery.class); + assertThat(context.getBean(KeyDiscovery.class)).isNotInstanceOf(CachingKeyDiscovery.class); + + PgpMailProperties properties = context.getBean(PgpMailProperties.class); + assertThat(properties.wkd().fallback()).isEqualTo(WkdFallback.ON_DNS_FAILURE); + assertThat(properties.cache().ttl()).isEqualTo(Duration.ofHours(24)); + assertThat(properties.cache().negativeTtl()).isEqualTo(Duration.ofHours(1)); + }); + } + + @Test + void pinnedKeysAreServedWithoutWkd() throws IOException { + Path key = tempDir.resolve("bob.asc"); + Files.writeString(key, PGPainless.getInstance().generateKey() + .modernKeyRing("Bob ").toCertificate().toAsciiArmoredString()); + + runner.withPropertyValues( + "mail.pgp.wkd.enabled=false", + "mail.pgp.static-keys[Bob@Legacy.org]=file:" + key) + .run(context -> { + assertThat(context).doesNotHaveBean(WkdFetcher.class).doesNotHaveBean(CachingKeyDiscovery.class); + + KeyDiscovery discovery = context.getBean(KeyDiscovery.class); + assertThat(discovery.discover("bob@legacy.org")) + .isInstanceOfSatisfying(DiscoveryResult.Found.class, + found -> assertThat(found.source()).isEqualTo(DiscoverySource.STATIC)); + assertThat(discovery.discover("carol@example.org")).isInstanceOf(DiscoveryResult.NotFound.class); + }); + } + + @Test + void failsFastOnUnreadablePinnedKey() { + runner.withPropertyValues("mail.pgp.static-keys[bob@legacy.org]=file:" + tempDir.resolve("missing.asc")) + .run(context -> assertThat(context).hasFailed()); + } + + @Test + void disabledCreatesNothing() { + runner.withPropertyValues("mail.pgp.enabled=false") + .run(context -> assertThat(context).doesNotHaveBean(KeyDiscovery.class)); + } +} diff --git a/mailsender-pgp-spring-boot-starter/pom.xml b/mailsender-pgp-spring-boot-starter/pom.xml new file mode 100644 index 0000000..0710eae --- /dev/null +++ b/mailsender-pgp-spring-boot-starter/pom.xml @@ -0,0 +1,26 @@ + + + 4.0.0 + + + io.github.sneakybugmaster.mailsender + mailsender-parent + 0.1.0-SNAPSHOT + + + mailsender-pgp-spring-boot-starter + Starter: OpenPGP-encrypted mail for Spring Boot + + + + io.github.sneakybugmaster.mailsender + mailsender-pgp-spring-boot-autoconfigure + + + org.springframework.boot + spring-boot-starter-mail + + + diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..f952c16 --- /dev/null +++ b/pom.xml @@ -0,0 +1,121 @@ + + + 4.0.0 + + io.github.sneakybugmaster.mailsender + mailsender-parent + 0.1.0-SNAPSHOT + pom + + mailsender + OpenPGP (WKD + PGP/MIME) encryption for Spring Boot mail + + + mailsender-pgp-core + mailsender-pgp-spring-boot-autoconfigure + mailsender-pgp-spring-boot-starter + mailsender-demo + + + + 27 + UTF-8 + + 4.1.1 + 2.0.4 + 0.1.3 + 3.3.0 + 2.1.14 + + + + + + org.springframework.boot + spring-boot-dependencies + ${spring-boot.version} + pom + import + + + io.github.sneakybugmaster.mailsender + mailsender-pgp-core + ${project.version} + + + io.github.sneakybugmaster.mailsender + mailsender-pgp-spring-boot-autoconfigure + ${project.version} + + + io.github.sneakybugmaster.mailsender + mailsender-pgp-spring-boot-starter + ${project.version} + + + org.pgpainless + pgpainless-core + ${pgpainless.version} + + + org.pgpainless + wkd-java + ${wkd-java.version} + + + com.github.ben-manes.caffeine + caffeine + ${caffeine.version} + + + com.icegreen + greenmail-junit5 + ${greenmail.version} + + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.14.0 + + true + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.3 + + + + + + org.apache.maven.plugins + maven-enforcer-plugin + 3.5.0 + + + enforce-java + + enforce + + + + + [27,) + + + + + + + + + From a788f7a48a5e4ba8fa0d6bdda540c77acaddb6d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 03:20:33 +0000 Subject: [PATCH 2/5] Add certificate validation and PGP/MIME encryption; rename to pro.thinhha.mailsender - validation: CertificateValidator checks revocation, expiry, matching user ID, a valid communications encryption subkey and the algorithm policy; uses the earlier expiry when PGPainless and Bouncy Castle disagree - crypto: PgpEncryptor (ASCII armor, hidden recipients for Bcc) - mime: PgpMimeEncryptor builds RFC 3156 multipart/encrypted from the whole CRLF-canonical inner entity, keeping envelope headers in the clear - autoconfigure: validator and encryptor beans - demo: encrypts when a valid key is found, plaintext otherwise - rename groupId and Java packages to pro.thinhha.mailsender Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G71TQ5jDETVwdUJxGAwaiT --- README.md | 11 +- docs/PLAN.md | 8 +- mailsender-demo/pom.xml | 4 +- .../mailsender/demo/TestMailRunner.java | 76 ---------- .../mailsender/demo/DemoApplication.java | 2 +- .../mailsender/demo/DemoProperties.java | 2 +- .../mailsender/demo/TestMailRunner.java | 123 ++++++++++++++++ .../src/main/resources/application.yml | 2 +- .../demo/EncryptedTestMailTest.java | 85 +++++++++++ .../demo/PlaintextTestMailTest.java} | 6 +- mailsender-pgp-core/pom.xml | 7 +- .../mailsender/pgp/crypto/PgpEncryptor.java | 56 +++++++ .../pgp/discovery/CachingKeyDiscovery.java | 2 +- .../pgp/discovery/Certificates.java | 2 +- .../pgp/discovery/CompositeKeyDiscovery.java | 2 +- .../pgp/discovery/DiscoveryResult.java | 2 +- .../pgp/discovery/DiscoverySource.java | 2 +- .../pgp/discovery/HostResolver.java | 2 +- .../pgp/discovery/HttpWkdFetcher.java | 2 +- .../pgp/discovery/KeyDiscovery.java | 2 +- .../pgp/discovery/StaticKeyDiscovery.java | 2 +- .../mailsender/pgp/discovery/WkdFallback.java | 2 +- .../mailsender/pgp/discovery/WkdFetcher.java | 2 +- .../pgp/discovery/WkdKeyDiscovery.java | 2 +- .../pgp/mime/EncryptedMultipart.java | 22 +++ .../mailsender/pgp/mime/PgpMimeEncryptor.java | 138 ++++++++++++++++++ .../pgp/validation/CertificateValidator.java | 100 +++++++++++++ .../pgp/validation/ValidationResult.java | 40 +++++ .../pgp/crypto/PgpEncryptorTest.java | 57 ++++++++ .../discovery/CachingKeyDiscoveryTest.java | 2 +- .../discovery/CompositeKeyDiscoveryTest.java | 2 +- .../pgp/discovery/HttpWkdFetcherTest.java | 2 +- .../mailsender/pgp/discovery/TestKeys.java | 2 +- .../pgp/discovery/WkdAddressTest.java | 2 +- .../pgp/discovery/WkdKeyDiscoveryTest.java | 2 +- .../pgp/mime/PgpMimeEncryptorTest.java | 136 +++++++++++++++++ .../validation/CertificateValidatorTest.java | 102 +++++++++++++ .../pom.xml | 4 +- .../PgpMailAutoConfiguration.java | 46 ++++-- .../pgp/autoconfigure/PgpMailProperties.java | 4 +- ...ot.autoconfigure.AutoConfiguration.imports | 2 +- .../PgpMailAutoConfigurationTest.java | 19 ++- mailsender-pgp-spring-boot-starter/pom.xml | 4 +- pom.xml | 8 +- 44 files changed, 958 insertions(+), 142 deletions(-) delete mode 100644 mailsender-demo/src/main/java/io/github/sneakybugmaster/mailsender/demo/TestMailRunner.java rename mailsender-demo/src/main/java/{io/github/sneakybugmaster => pro/thinhha}/mailsender/demo/DemoApplication.java (88%) rename mailsender-demo/src/main/java/{io/github/sneakybugmaster => pro/thinhha}/mailsender/demo/DemoProperties.java (91%) create mode 100644 mailsender-demo/src/main/java/pro/thinhha/mailsender/demo/TestMailRunner.java create mode 100644 mailsender-demo/src/test/java/pro/thinhha/mailsender/demo/EncryptedTestMailTest.java rename mailsender-demo/src/test/java/{io/github/sneakybugmaster/mailsender/demo/TestMailRunnerTest.java => pro/thinhha/mailsender/demo/PlaintextTestMailTest.java} (95%) create mode 100644 mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/crypto/PgpEncryptor.java rename mailsender-pgp-core/src/main/java/{io/github/sneakybugmaster => pro/thinhha}/mailsender/pgp/discovery/CachingKeyDiscovery.java (98%) rename mailsender-pgp-core/src/main/java/{io/github/sneakybugmaster => pro/thinhha}/mailsender/pgp/discovery/Certificates.java (91%) rename mailsender-pgp-core/src/main/java/{io/github/sneakybugmaster => pro/thinhha}/mailsender/pgp/discovery/CompositeKeyDiscovery.java (95%) rename mailsender-pgp-core/src/main/java/{io/github/sneakybugmaster => pro/thinhha}/mailsender/pgp/discovery/DiscoveryResult.java (95%) rename mailsender-pgp-core/src/main/java/{io/github/sneakybugmaster => pro/thinhha}/mailsender/pgp/discovery/DiscoverySource.java (83%) rename mailsender-pgp-core/src/main/java/{io/github/sneakybugmaster => pro/thinhha}/mailsender/pgp/discovery/HostResolver.java (90%) rename mailsender-pgp-core/src/main/java/{io/github/sneakybugmaster => pro/thinhha}/mailsender/pgp/discovery/HttpWkdFetcher.java (97%) rename mailsender-pgp-core/src/main/java/{io/github/sneakybugmaster => pro/thinhha}/mailsender/pgp/discovery/KeyDiscovery.java (92%) rename mailsender-pgp-core/src/main/java/{io/github/sneakybugmaster => pro/thinhha}/mailsender/pgp/discovery/StaticKeyDiscovery.java (95%) rename mailsender-pgp-core/src/main/java/{io/github/sneakybugmaster => pro/thinhha}/mailsender/pgp/discovery/WkdFallback.java (89%) rename mailsender-pgp-core/src/main/java/{io/github/sneakybugmaster => pro/thinhha}/mailsender/pgp/discovery/WkdFetcher.java (90%) rename mailsender-pgp-core/src/main/java/{io/github/sneakybugmaster => pro/thinhha}/mailsender/pgp/discovery/WkdKeyDiscovery.java (98%) create mode 100644 mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/mime/EncryptedMultipart.java create mode 100644 mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/mime/PgpMimeEncryptor.java create mode 100644 mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/validation/CertificateValidator.java create mode 100644 mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/validation/ValidationResult.java create mode 100644 mailsender-pgp-core/src/test/java/pro/thinhha/mailsender/pgp/crypto/PgpEncryptorTest.java rename mailsender-pgp-core/src/test/java/{io/github/sneakybugmaster => pro/thinhha}/mailsender/pgp/discovery/CachingKeyDiscoveryTest.java (98%) rename mailsender-pgp-core/src/test/java/{io/github/sneakybugmaster => pro/thinhha}/mailsender/pgp/discovery/CompositeKeyDiscoveryTest.java (96%) rename mailsender-pgp-core/src/test/java/{io/github/sneakybugmaster => pro/thinhha}/mailsender/pgp/discovery/HttpWkdFetcherTest.java (97%) rename mailsender-pgp-core/src/test/java/{io/github/sneakybugmaster => pro/thinhha}/mailsender/pgp/discovery/TestKeys.java (94%) rename mailsender-pgp-core/src/test/java/{io/github/sneakybugmaster => pro/thinhha}/mailsender/pgp/discovery/WkdAddressTest.java (92%) rename mailsender-pgp-core/src/test/java/{io/github/sneakybugmaster => pro/thinhha}/mailsender/pgp/discovery/WkdKeyDiscoveryTest.java (99%) create mode 100644 mailsender-pgp-core/src/test/java/pro/thinhha/mailsender/pgp/mime/PgpMimeEncryptorTest.java create mode 100644 mailsender-pgp-core/src/test/java/pro/thinhha/mailsender/pgp/validation/CertificateValidatorTest.java rename mailsender-pgp-spring-boot-autoconfigure/src/main/java/{io/github/sneakybugmaster => pro/thinhha}/mailsender/pgp/autoconfigure/PgpMailAutoConfiguration.java (70%) rename mailsender-pgp-spring-boot-autoconfigure/src/main/java/{io/github/sneakybugmaster => pro/thinhha}/mailsender/pgp/autoconfigure/PgpMailProperties.java (92%) rename mailsender-pgp-spring-boot-autoconfigure/src/test/java/{io/github/sneakybugmaster => pro/thinhha}/mailsender/pgp/autoconfigure/PgpMailAutoConfigurationTest.java (80%) 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} From 3bce057eab33c82ffb90dcac4ff1bb1ab5767668 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 03:27:40 +0000 Subject: [PATCH 3/5] 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: + *

    + *
  • A REQUIRE recipient without a usable key → {@link EncryptionPolicyException}, nothing is sent.
  • + *
  • Nobody gets encryption → one plaintext copy to everyone.
  • + *
  • Otherwise one encrypted copy for To/Cc recipients with keys, one plaintext copy for the other To/Cc + * recipients, and an own copy per Bcc recipient (encrypted with hidden key IDs when possible). + * With {@link MixedMode#FAIL}, a mix of encrypted and plaintext recipients is refused instead.
  • + *
+ * 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); + } +} From f63636f8f46596901c13158c3148460221ddeac8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 03:33:17 +0000 Subject: [PATCH 4/5] Add metrics, protected headers and pgpkeys actuator endpoint - metrics (optional Micrometer): per-recipient outcome/policy/reason counter, refused counter, key lookup timer below the cache, cache size gauge - SendReport.RecipientOutcome gains a structured PlaintextReason - protected headers (RFC 9788, mail.pgp.protected-headers, off by default): real headers inside the encrypted part, outer Subject "[...]" - encrypting keeps the caller's Message-ID - pgpkeys endpoint (optional Actuator): inspect policy, lookup and key validity per address; evict one or all cached lookups - KeyDiscoveryCustomizer and SecureMailSenderCustomizer extension points Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G71TQ5jDETVwdUJxGAwaiT --- README.md | 6 + docs/PLAN.md | 23 +++- .../src/main/resources/application.yml | 1 + .../mailsender/pgp/mime/PgpMimeEncryptor.java | 62 +++++++-- .../pgp/mime/PgpMimeEncryptorTest.java | 32 +++++ .../pom.xml | 25 ++++ .../autoconfigure/KeyDiscoveryCustomizer.java | 12 ++ .../pgp/autoconfigure/PgpKeysEndpoint.java | 121 ++++++++++++++++++ .../PgpMailAutoConfiguration.java | 19 ++- .../PgpMailEndpointAutoConfiguration.java | 29 +++++ .../PgpMailMetricsAutoConfiguration.java | 53 ++++++++ .../pgp/autoconfigure/PgpMailProperties.java | 3 + .../SecureMailSenderCustomizer.java | 10 ++ .../pgp/metrics/MeteredKeyDiscovery.java | 41 ++++++ .../pgp/metrics/MeteredSecureMailSender.java | 78 +++++++++++ .../pgp/sender/BuiltInSecureMailSender.java | 13 ++ .../pgp/sender/RelaySecureMailSender.java | 3 +- .../mailsender/pgp/sender/SendReport.java | 18 ++- ...ot.autoconfigure.AutoConfiguration.imports | 2 + .../autoconfigure/PgpKeysEndpointWebTest.java | 56 ++++++++ ...ailObservabilityAutoConfigurationTest.java | 94 ++++++++++++++ .../metrics/MeteredSecureMailSenderTest.java | 88 +++++++++++++ 22 files changed, 767 insertions(+), 22 deletions(-) create mode 100644 mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/KeyDiscoveryCustomizer.java create mode 100644 mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpKeysEndpoint.java create mode 100644 mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailEndpointAutoConfiguration.java create mode 100644 mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailMetricsAutoConfiguration.java create mode 100644 mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/SecureMailSenderCustomizer.java create mode 100644 mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/metrics/MeteredKeyDiscovery.java create mode 100644 mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/metrics/MeteredSecureMailSender.java create mode 100644 mailsender-pgp-spring-boot-autoconfigure/src/test/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpKeysEndpointWebTest.java create mode 100644 mailsender-pgp-spring-boot-autoconfigure/src/test/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailObservabilityAutoConfigurationTest.java create mode 100644 mailsender-pgp-spring-boot-autoconfigure/src/test/java/pro/thinhha/mailsender/pgp/metrics/MeteredSecureMailSenderTest.java diff --git a/README.md b/README.md index e240426..fb5bf3a 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,12 @@ mail: Map keys that are addresses need the `[...]` brackets. See [docs/PLAN.md](docs/PLAN.md) for all options. +- `mail.pgp.protected-headers=true` encrypts the real Subject too (RFC 9788); the visible Subject becomes `[...]`. +- With Micrometer, `mail.pgp.recipients{outcome,policy,reason}`, `mail.pgp.refused`, `mail.pgp.key.lookup` and + `mail.pgp.key.cache.size` are recorded. +- With Actuator, expose `pgpkeys` to inspect an address (`GET /actuator/pgpkeys/{email}`) or evict cached keys + (`DELETE /actuator/pgpkeys/{email}`). + ## Demo app The demo app sends `DEMO_COUNT` test mails (default: 1 to `test@thinhha.pro`) through `SecureMailSender` and logs diff --git a/docs/PLAN.md b/docs/PLAN.md index 7e3a2cf..487549d 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -155,10 +155,22 @@ mail.pgp: relay: { ... } # see RELAY mode ``` -## Observability -- `mail.pgp.sent{mode=encrypted|plaintext}`, `mail.pgp.downgrade{reason=no_key|unreachable|invalid_key}` -- `mail.pgp.wkd.lookup{result=found|not_found|unreachable, method=advanced|direct}` -- Caffeine cache metrics; actuator endpoint `pgpkeys` to inspect/evict a cached address. +## Observability (M8) +Micrometer is optional; with a `MeterRegistry` bean these are registered: +- `mail.pgp.recipients{outcome=encrypted|plaintext|relayed, policy, reason=none|not_found|unreachable|rejected|plaintext_policy}` + — a downgrade is `outcome=plaintext, policy=opportunistic` +- `mail.pgp.refused` — messages refused by the policy +- `mail.pgp.key.lookup{result=found|not_found|unreachable, source=wkd_advanced|wkd_direct|none}` — timer for lookups + not answered from the cache +- `mail.pgp.key.cache.size` + +Actuator is optional; when the `pgpkeys` endpoint is exposed +(`management.endpoints.web.exposure.include=pgpkeys`): +- `GET /actuator/pgpkeys` — cache size +- `GET /actuator/pgpkeys/{email}` — policy, lookup result, and validation of each key +- `DELETE /actuator/pgpkeys/{email}`, `DELETE /actuator/pgpkeys` — evict cached lookups (key rotation) + +Extension points: `KeyDiscoveryCustomizer` (wraps WKD discovery below the cache) and `SecureMailSenderCustomizer`. ## Milestones 1. **Skeleton** — parent POM, modules, CI. ✅ @@ -179,4 +191,5 @@ mail.pgp: 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** — Micrometer metrics, protected headers, actuator endpoint, docs. +8. **Hardening** — Micrometer metrics, protected headers (RFC 9788, opt-in), actuator endpoint, docs. ✅ + Encrypting no longer regenerates the caller's Message-ID. diff --git a/mailsender-demo/src/main/resources/application.yml b/mailsender-demo/src/main/resources/application.yml index d8e8734..d2dd7c6 100644 --- a/mailsender-demo/src/main/resources/application.yml +++ b/mailsender-demo/src/main/resources/application.yml @@ -18,6 +18,7 @@ mail: mode: ${MAIL_PGP_MODE:BUILT_IN} # BUILT_IN | RELAY default-policy: ${MAIL_PGP_DEFAULT_POLICY:OPPORTUNISTIC} mixed-mode: SPLIT + protected-headers: ${MAIL_PGP_PROTECTED_HEADERS:false} # recipients: # "[*@partner.example]": REQUIRE # Readable sent copies: the sender's own PUBLIC key per From address. diff --git a/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/mime/PgpMimeEncryptor.java b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/mime/PgpMimeEncryptor.java index 1dff6f8..c07ed16 100644 --- a/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/mime/PgpMimeEncryptor.java +++ b/mailsender-pgp-core/src/main/java/pro/thinhha/mailsender/pgp/mime/PgpMimeEncryptor.java @@ -7,6 +7,7 @@ import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.util.Collection; import java.util.Enumeration; +import java.util.List; import java.util.Locale; import java.util.Objects; import java.util.Properties; @@ -19,6 +20,7 @@ import jakarta.activation.DataHandler; import jakarta.mail.Header; import jakarta.mail.MessagingException; import jakarta.mail.Session; +import jakarta.mail.internet.ContentType; import jakarta.mail.internet.MimeBodyPart; import jakarta.mail.internet.MimeMessage; import jakarta.mail.util.ByteArrayDataSource; @@ -28,16 +30,29 @@ import jakarta.mail.util.ByteArrayDataSource; *

* 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. + *

+ * With protected headers (RFC 9788) the inner entity also carries the real From/To/Cc/Reply-To/Date/Message-ID/ + * Subject and {@code protected-headers="v1"}, and the outer Subject is replaced by {@value #OBSCURED_SUBJECT}. + * Clients without support show {@value #OBSCURED_SUBJECT} as the subject, so this is off by default. */ public final class PgpMimeEncryptor { private static final Session PARSE_SESSION = Session.getInstance(new Properties()); private static final byte[] VERSION_PART = "Version: 1\r\n".getBytes(StandardCharsets.US_ASCII); + public static final String OBSCURED_SUBJECT = "[...]"; + private static final List PROTECTED_HEADERS = + List.of("From", "To", "Cc", "Reply-To", "Date", "Message-ID", "Subject"); private final PgpEncryptor encryptor; + private final boolean protectHeaders; public PgpMimeEncryptor(PgpEncryptor encryptor) { + this(encryptor, false); + } + + public PgpMimeEncryptor(PgpEncryptor encryptor, boolean protectHeaders) { this.encryptor = Objects.requireNonNull(encryptor, "encryptor"); + this.protectHeaders = protectHeaders; } /** @@ -51,33 +66,58 @@ public final class PgpMimeEncryptor { Collection hiddenRecipients, Supplier newMessage) throws MessagingException, IOException { MimeMessage source = snapshot(original); - byte[] ciphertext = encryptor.encrypt(innerEntity(source), recipients, hiddenRecipients); + byte[] ciphertext = encryptor.encrypt(innerEntity(source, protectHeaders), recipients, hiddenRecipients); MimeMessage outer = newMessage.get(); copyEnvelopeHeaders(source, outer); + if (protectHeaders && outer.getHeader("Subject") != null) { + outer.setHeader("Subject", OBSCURED_SUBJECT); + } outer.setContent(pgpMimeBody(ciphertext)); outer.saveChanges(); return outer; } - /** Serializes and re-parses the message so headers are final and the encoded body is available. */ + /** + * Serializes and re-parses the message so headers are final and the encoded body is available. Keeps the + * caller's Message-ID, which {@code saveChanges()} would otherwise regenerate. + */ private static MimeMessage snapshot(MimeMessage original) throws MessagingException, IOException { + String messageId = original.getMessageID(); original.saveChanges(); + if (messageId != null) { + original.setHeader("Message-ID", messageId); + } ByteArrayOutputStream buffer = new ByteArrayOutputStream(); original.writeTo(buffer); return new MimeMessage(PARSE_SESSION, new ByteArrayInputStream(buffer.toByteArray())); } - /** The {@code Content-*} headers plus the still-encoded body, with CRLF line endings. */ - static byte[] innerEntity(MimeMessage source) throws MessagingException, IOException { + /** + * The {@code Content-*} headers (plus the protected headers, if enabled) and the still-encoded body, with CRLF + * line endings. + */ + static byte[] innerEntity(MimeMessage source, boolean protectHeaders) throws MessagingException, IOException { ByteArrayOutputStream entity = new ByteArrayOutputStream(); + if (protectHeaders) { + for (String name : PROTECTED_HEADERS) { + String[] values = source.getHeader(name); + if (values != null) { + for (String value : values) { + writeHeader(entity, name + ": " + value); + } + } + } + } Enumeration lines = source.getAllHeaderLines(); while (lines.hasMoreElements()) { String line = lines.nextElement(); - if (isContentHeader(line)) { - entity.write(line.getBytes(StandardCharsets.UTF_8)); - entity.write('\r'); - entity.write('\n'); + if (protectHeaders && line.regionMatches(true, 0, "Content-Type:", 0, "Content-Type:".length())) { + ContentType type = new ContentType(source.getContentType()); + type.setParameter("protected-headers", "v1"); + writeHeader(entity, "Content-Type: " + type); + } else if (isContentHeader(line)) { + writeHeader(entity, line); } } entity.write('\r'); @@ -88,6 +128,12 @@ public final class PgpMimeEncryptor { return toCrlf(entity.toByteArray()); } + private static void writeHeader(ByteArrayOutputStream entity, String line) throws IOException { + entity.write(line.getBytes(StandardCharsets.UTF_8)); + entity.write('\r'); + entity.write('\n'); + } + private static void copyEnvelopeHeaders(MimeMessage source, MimeMessage outer) throws MessagingException { Enumeration

headers = source.getAllHeaders(); while (headers.hasMoreElements()) { 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 index 987cab5..397733f 100644 --- 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 @@ -102,6 +102,38 @@ class PgpMimeEncryptorTest { assertThat(original.getContent()).isEqualTo("plain"); } + @Test + void protectedHeadersMoveTheSubjectInside() throws Exception { + PgpMimeEncryptor protecting = new PgpMimeEncryptor(new PgpEncryptor(), true); + MimeMessage original = textMessage("body"); + original.setHeader("Message-ID", ""); + + MimeMessage wire = sendAndReceive(protecting.encrypt(original, List.of(ALICE.toCertificate()), List.of(), + () -> new MimeMessage(SESSION))); + + assertThat(wire.getSubject()).isEqualTo(PgpMimeEncryptor.OBSCURED_SUBJECT); + assertThat(original.getMessageID()).as("caller's Message-ID is kept").isEqualTo(""); + byte[] armored = readAll(((Multipart) wire.getContent()).getBodyPart(1)).getBytes(StandardCharsets.US_ASCII); + MimeBodyPart inner = new MimeBodyPart(new ByteArrayInputStream(PgpEncryptorTest.decrypt(armored, ALICE))); + assertThat(new ContentType(inner.getContentType()).getParameter("protected-headers")).isEqualTo("v1"); + assertThat(inner.getHeader("Subject")).containsExactly("Quarterly numbers"); + assertThat(inner.getHeader("To")).containsExactly("alice@example.org"); + assertThat(inner.getHeader("Message-ID")).containsExactly(""); + assertThat(inner.getContent()).isEqualTo("body"); + } + + @Test + void headersAreNotProtectedByDefault() throws Exception { + MimeMessage wire = sendAndReceive(mimeEncryptor.encrypt(textMessage("body"), List.of(ALICE.toCertificate()), + List.of(), () -> new MimeMessage(SESSION))); + + byte[] armored = readAll(((Multipart) wire.getContent()).getBodyPart(1)).getBytes(StandardCharsets.US_ASCII); + MimeBodyPart inner = new MimeBodyPart(new ByteArrayInputStream(PgpEncryptorTest.decrypt(armored, ALICE))); + assertThat(wire.getSubject()).isEqualTo("Quarterly numbers"); + assertThat(inner.getHeader("Subject")).isNull(); + assertThat(new ContentType(inner.getContentType()).getParameter("protected-headers")).isNull(); + } + private static MimeMessage textMessage(String text) throws Exception { MimeMessage message = new MimeMessage(SESSION); message.setFrom(new InternetAddress("sender@example.com")); diff --git a/mailsender-pgp-spring-boot-autoconfigure/pom.xml b/mailsender-pgp-spring-boot-autoconfigure/pom.xml index b9a80ea..b88e61e 100644 --- a/mailsender-pgp-spring-boot-autoconfigure/pom.xml +++ b/mailsender-pgp-spring-boot-autoconfigure/pom.xml @@ -26,6 +26,21 @@ org.springframework spring-context-support + + io.micrometer + micrometer-core + true + + + org.springframework.boot + spring-boot-actuator + true + + + org.springframework.boot + spring-boot-actuator-autoconfigure + true + org.springframework.boot spring-boot-configuration-processor @@ -47,6 +62,16 @@ greenmail-junit5 test + + org.springframework.boot + spring-boot-starter-webmvc + test + + + org.springframework.boot + spring-boot-starter-actuator + test + diff --git a/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/KeyDiscoveryCustomizer.java b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/KeyDiscoveryCustomizer.java new file mode 100644 index 0000000..30190fc --- /dev/null +++ b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/KeyDiscoveryCustomizer.java @@ -0,0 +1,12 @@ +package pro.thinhha.mailsender.pgp.autoconfigure; + +import pro.thinhha.mailsender.pgp.discovery.KeyDiscovery; + +/** + * Wraps the WKD discovery before it is cached, so the wrapper sees real lookups only (e.g. metrics, tracing). + */ +@FunctionalInterface +public interface KeyDiscoveryCustomizer { + + KeyDiscovery customize(KeyDiscovery wkdDiscovery); +} diff --git a/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpKeysEndpoint.java b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpKeysEndpoint.java new file mode 100644 index 0000000..07c989d --- /dev/null +++ b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpKeysEndpoint.java @@ -0,0 +1,121 @@ +package pro.thinhha.mailsender.pgp.autoconfigure; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.bouncycastle.openpgp.api.OpenPGPCertificate; +import org.springframework.boot.actuate.endpoint.annotation.DeleteOperation; +import org.springframework.boot.actuate.endpoint.annotation.Endpoint; +import org.springframework.boot.actuate.endpoint.annotation.ReadOperation; +import org.springframework.boot.actuate.endpoint.annotation.Selector; + +import pro.thinhha.mailsender.pgp.delivery.EncryptionPolicy; +import pro.thinhha.mailsender.pgp.delivery.PolicyResolver; +import pro.thinhha.mailsender.pgp.discovery.CachingKeyDiscovery; +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; + +/** + * {@code /actuator/pgpkeys}: inspect how an address would be handled, and evict cached lookups after a recipient + * rotated their key. + *
    + *
  • {@code GET pgpkeys} — cache size
  • + *
  • {@code GET pgpkeys/{email}} — policy, key lookup (through the cache) and validation of each key
  • + *
  • {@code DELETE pgpkeys/{email}} / {@code DELETE pgpkeys} — evict one address / everything
  • + *
+ */ +@Endpoint(id = "pgpkeys") +public class PgpKeysEndpoint { + + private final KeyDiscovery discovery; + private final CachingKeyDiscovery cache; + private final CertificateValidator validator; + private final PolicyResolver policies; + + /** @param cache {@code null} when WKD is disabled */ + public PgpKeysEndpoint(KeyDiscovery discovery, CachingKeyDiscovery cache, CertificateValidator validator, + PolicyResolver policies) { + this.discovery = discovery; + this.cache = cache; + this.validator = validator; + this.policies = policies; + } + + @ReadOperation + public Map summary() { + Map summary = new LinkedHashMap<>(); + summary.put("wkd", cache != null); + summary.put("cachedAddresses", cache == null ? 0 : cache.estimatedSize()); + return summary; + } + + @ReadOperation + public AddressReport address(@Selector String email) { + EncryptionPolicy policy = policies.resolve(email); + DiscoveryResult result = discovery.discover(email); + List keys = new ArrayList<>(); + String lookup; + String detail = null; + switch (result) { + case DiscoveryResult.Found found -> { + lookup = "found:" + found.source(); + for (OpenPGPCertificate certificate : found.certificates()) { + keys.add(keyReport(certificate, email)); + } + } + case DiscoveryResult.NotFound ignored -> lookup = "not_found"; + case DiscoveryResult.Unreachable unreachable -> { + lookup = "unreachable"; + detail = unreachable.reason(); + } + } + boolean encrypt = policy != EncryptionPolicy.PLAINTEXT && keys.stream().anyMatch(k -> k.valid); + return new AddressReport(email, policy, lookup, detail, encrypt, keys); + } + + @DeleteOperation + public void evict(@Selector String email) { + if (cache != null) { + cache.evict(email); + } + } + + @DeleteOperation + public void evictAll() { + if (cache != null) { + cache.evictAll(); + } + } + + private KeyReport keyReport(OpenPGPCertificate certificate, String email) { + ValidationResult validation = validator.validate(certificate, email); + List userIds = certificate.getAllUserIds().stream() + .map(OpenPGPCertificate.OpenPGPUserId::getUserId).toList(); + Instant expires = certificate.getExpirationTime() == null ? null : certificate.getExpirationTime().toInstant(); + return switch (validation) { + case ValidationResult.Valid ignored -> + new KeyReport(fingerprint(certificate), userIds, expires, true, null, null); + case ValidationResult.Rejected rejected -> new KeyReport(fingerprint(certificate), userIds, expires, false, + rejected.reason().name(), rejected.detail()); + }; + } + + private static String fingerprint(OpenPGPCertificate certificate) { + return HexFormat.of().withUpperCase().formatHex(certificate.getFingerprint()); + } + + /** @param wouldEncrypt whether a message to this address alone would currently be encrypted */ + public record AddressReport(String address, EncryptionPolicy policy, String lookup, String lookupDetail, + boolean wouldEncrypt, List keys) { + } + + public record KeyReport(String fingerprint, List userIds, Instant expires, boolean valid, + String rejectedReason, String rejectedDetail) { + } +} 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 6deae4e..433c89b 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 @@ -77,9 +77,12 @@ public class PgpMailAutoConfiguration { @ConditionalOnMissingBean @ConditionalOnProperty(prefix = "mail.pgp.wkd", name = "enabled", havingValue = "true", matchIfMissing = true) CachingKeyDiscovery pgpCachingWkdDiscovery(PgpMailProperties properties, WkdFetcher fetcher, - ObjectProvider hostResolver) { - WkdKeyDiscovery wkd = new WkdKeyDiscovery(fetcher, hostResolver.getIfAvailable(HostResolver::system), + ObjectProvider hostResolver, ObjectProvider customizers) { + KeyDiscovery wkd = new WkdKeyDiscovery(fetcher, hostResolver.getIfAvailable(HostResolver::system), properties.wkd().fallback()); + for (KeyDiscoveryCustomizer customizer : customizers.orderedStream().toList()) { + wkd = customizer.customize(wkd); + } PgpMailProperties.Cache cache = properties.cache(); return new CachingKeyDiscovery(wkd, new CachingKeyDiscovery.Settings( cache.ttl(), cache.negativeTtl(), cache.errorTtl(), cache.maxSize())); @@ -109,8 +112,8 @@ public class PgpMailAutoConfiguration { @Bean @ConditionalOnMissingBean - PgpMimeEncryptor pgpMimeEncryptor(PgpEncryptor encryptor) { - return new PgpMimeEncryptor(encryptor); + PgpMimeEncryptor pgpMimeEncryptor(PgpMailProperties properties, PgpEncryptor encryptor) { + return new PgpMimeEncryptor(encryptor, properties.protectedHeaders()); } @Bean @@ -159,8 +162,8 @@ public class PgpMailAutoConfiguration { @ConditionalOnMissingBean SecureMailSender pgpSecureMailSender(PgpMailProperties properties, ObjectProvider mailSender, ObjectProvider planner, ObjectProvider assembler, - PolicyResolver policies) { - return switch (properties.mode()) { + PolicyResolver policies, ObjectProvider customizers) { + SecureMailSender sender = switch (properties.mode()) { case BUILT_IN -> { JavaMailSender delegate = mailSender.getIfAvailable(); if (delegate == null) { @@ -176,6 +179,10 @@ public class PgpMailAutoConfiguration { yield new RelaySecureMailSender(relaySender(relay), policies, relay.policyHeader()); } }; + for (SecureMailSenderCustomizer customizer : customizers.orderedStream().toList()) { + sender = customizer.customize(sender); + } + return sender; } /** Not a bean: a second JavaMailSender would make Boot's own {@code spring.mail} sender back off. */ diff --git a/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailEndpointAutoConfiguration.java b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailEndpointAutoConfiguration.java new file mode 100644 index 0000000..4914dc4 --- /dev/null +++ b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailEndpointAutoConfiguration.java @@ -0,0 +1,29 @@ +package pro.thinhha.mailsender.pgp.autoconfigure; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.actuate.autoconfigure.endpoint.condition.ConditionalOnAvailableEndpoint; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; + +import pro.thinhha.mailsender.pgp.delivery.PolicyResolver; +import pro.thinhha.mailsender.pgp.discovery.CachingKeyDiscovery; +import pro.thinhha.mailsender.pgp.discovery.KeyDiscovery; +import pro.thinhha.mailsender.pgp.validation.CertificateValidator; + +/** Registers {@link PgpKeysEndpoint} when Actuator is present and the endpoint is enabled and exposed. */ +@AutoConfiguration(after = PgpMailAutoConfiguration.class) +@ConditionalOnClass(name = "org.springframework.boot.actuate.endpoint.annotation.Endpoint") +@ConditionalOnBean(PolicyResolver.class) +public class PgpMailEndpointAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + @ConditionalOnAvailableEndpoint + PgpKeysEndpoint pgpKeysEndpoint(KeyDiscovery discovery, ObjectProvider cache, + CertificateValidator validator, PolicyResolver policies) { + return new PgpKeysEndpoint(discovery, cache.getIfAvailable(), validator, policies); + } +} diff --git a/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailMetricsAutoConfiguration.java b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailMetricsAutoConfiguration.java new file mode 100644 index 0000000..8ad0722 --- /dev/null +++ b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailMetricsAutoConfiguration.java @@ -0,0 +1,53 @@ +package pro.thinhha.mailsender.pgp.autoconfigure; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; + +import io.micrometer.core.instrument.Gauge; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.binder.MeterBinder; +import pro.thinhha.mailsender.pgp.discovery.CachingKeyDiscovery; +import pro.thinhha.mailsender.pgp.metrics.MeteredKeyDiscovery; +import pro.thinhha.mailsender.pgp.metrics.MeteredSecureMailSender; + +/** + * Micrometer metrics when a {@link MeterRegistry} bean exists: + *
    + *
  • {@code mail.pgp.recipients{outcome, policy, reason}} — counter per recipient
  • + *
  • {@code mail.pgp.refused} — messages refused by the policy
  • + *
  • {@code mail.pgp.key.lookup{result, source}} — timer for lookups not answered from the cache
  • + *
  • {@code mail.pgp.key.cache.size} — cached addresses
  • + *
+ */ +@AutoConfiguration +@ConditionalOnClass(MeterRegistry.class) +@ConditionalOnProperty(prefix = "mail.pgp", name = "enabled", havingValue = "true", matchIfMissing = true) +public class PgpMailMetricsAutoConfiguration { + + @Bean + KeyDiscoveryCustomizer pgpKeyDiscoveryMetrics(ObjectProvider registry) { + return discovery -> { + MeterRegistry meterRegistry = registry.getIfAvailable(); + return meterRegistry == null ? discovery : new MeteredKeyDiscovery(discovery, meterRegistry); + }; + } + + @Bean + SecureMailSenderCustomizer pgpSecureMailSenderMetrics(ObjectProvider registry) { + return sender -> { + MeterRegistry meterRegistry = registry.getIfAvailable(); + return meterRegistry == null ? sender : new MeteredSecureMailSender(sender, meterRegistry); + }; + } + + @Bean + MeterBinder pgpKeyCacheMetrics(ObjectProvider cache) { + return registry -> cache.ifAvailable(c -> Gauge.builder("mail.pgp.key.cache.size", c, + CachingKeyDiscovery::estimatedSize) + .description("Addresses with a cached key lookup result") + .register(registry)); + } +} 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 05a1fcf..cadb51a 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 @@ -22,6 +22,8 @@ import pro.thinhha.mailsender.pgp.discovery.WkdFallback; * @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 protectedHeaders move the real Subject (and other headers) into the encrypted part (RFC 9788); the + * visible Subject becomes {@code [...]} * @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 @@ -32,6 +34,7 @@ public record PgpMailProperties( @DefaultValue("BUILT_IN") Mode mode, @DefaultValue("OPPORTUNISTIC") EncryptionPolicy defaultPolicy, @DefaultValue("SPLIT") MixedMode mixedMode, + @DefaultValue("false") boolean protectedHeaders, Map recipients, @DefaultValue Wkd wkd, @DefaultValue Cache cache, diff --git a/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/SecureMailSenderCustomizer.java b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/SecureMailSenderCustomizer.java new file mode 100644 index 0000000..fc61254 --- /dev/null +++ b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/autoconfigure/SecureMailSenderCustomizer.java @@ -0,0 +1,10 @@ +package pro.thinhha.mailsender.pgp.autoconfigure; + +import pro.thinhha.mailsender.pgp.sender.SecureMailSender; + +/** Wraps the auto-configured {@link SecureMailSender} (e.g. metrics, auditing). */ +@FunctionalInterface +public interface SecureMailSenderCustomizer { + + SecureMailSender customize(SecureMailSender sender); +} diff --git a/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/metrics/MeteredKeyDiscovery.java b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/metrics/MeteredKeyDiscovery.java new file mode 100644 index 0000000..0b2a1cd --- /dev/null +++ b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/metrics/MeteredKeyDiscovery.java @@ -0,0 +1,41 @@ +package pro.thinhha.mailsender.pgp.metrics; + +import java.util.Locale; +import java.util.Objects; + +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Timer; +import pro.thinhha.mailsender.pgp.discovery.DiscoveryResult; +import pro.thinhha.mailsender.pgp.discovery.KeyDiscovery; + +/** Times each real key lookup: {@code mail.pgp.key.lookup{result, source}}. */ +public final class MeteredKeyDiscovery implements KeyDiscovery { + + static final String LOOKUP = "mail.pgp.key.lookup"; + + private final KeyDiscovery delegate; + private final MeterRegistry registry; + + public MeteredKeyDiscovery(KeyDiscovery delegate, MeterRegistry registry) { + this.delegate = Objects.requireNonNull(delegate, "delegate"); + this.registry = Objects.requireNonNull(registry, "registry"); + } + + @Override + public DiscoveryResult discover(String email) { + Timer.Sample sample = Timer.start(registry); + DiscoveryResult result = delegate.discover(email); + sample.stop(Timer.builder(LOOKUP) + .description("Key lookups that were not answered from the cache") + .tag("result", switch (result) { + case DiscoveryResult.Found ignored -> "found"; + case DiscoveryResult.NotFound ignored -> "not_found"; + case DiscoveryResult.Unreachable ignored -> "unreachable"; + }) + .tag("source", result instanceof DiscoveryResult.Found found + ? found.source().name().toLowerCase(Locale.ROOT) + : "none") + .register(registry)); + return result; + } +} diff --git a/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/metrics/MeteredSecureMailSender.java b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/metrics/MeteredSecureMailSender.java new file mode 100644 index 0000000..2d1d8c4 --- /dev/null +++ b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/metrics/MeteredSecureMailSender.java @@ -0,0 +1,78 @@ +package pro.thinhha.mailsender.pgp.metrics; + +import java.util.Locale; +import java.util.Objects; + +import org.springframework.mail.SimpleMailMessage; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import jakarta.mail.internet.MimeMessage; +import pro.thinhha.mailsender.pgp.delivery.EncryptionPolicyException; +import pro.thinhha.mailsender.pgp.sender.SecureMailSender; +import pro.thinhha.mailsender.pgp.sender.SendReport; + +/** + * Counts per recipient {@code mail.pgp.recipients{outcome, policy, reason}} and refused messages + * {@code mail.pgp.refused}. A plaintext recipient with policy {@code opportunistic} is a downgrade. + */ +public final class MeteredSecureMailSender implements SecureMailSender { + + static final String RECIPIENTS = "mail.pgp.recipients"; + static final String REFUSED = "mail.pgp.refused"; + + private final SecureMailSender delegate; + private final MeterRegistry registry; + + public MeteredSecureMailSender(SecureMailSender delegate, MeterRegistry registry) { + this.delegate = Objects.requireNonNull(delegate, "delegate"); + this.registry = Objects.requireNonNull(registry, "registry"); + } + + @Override + public MimeMessage createMimeMessage() { + return delegate.createMimeMessage(); + } + + @Override + public SendReport send(MimeMessage message) { + try { + return record(delegate.send(message)); + } catch (EncryptionPolicyException e) { + refused(); + throw e; + } + } + + @Override + public SendReport send(SimpleMailMessage message) { + try { + return record(delegate.send(message)); + } catch (EncryptionPolicyException e) { + refused(); + throw e; + } + } + + private SendReport record(SendReport report) { + for (SendReport.RecipientOutcome recipient : report.recipients()) { + Counter.builder(RECIPIENTS) + .description("Recipients by delivery outcome") + .tag("outcome", lower(recipient.outcome())) + .tag("policy", lower(recipient.policy())) + .tag("reason", lower(recipient.reason())) + .register(registry) + .increment(); + } + return report; + } + + private void refused() { + Counter.builder(REFUSED).description("Messages refused by the encryption policy").register(registry) + .increment(); + } + + private static String lower(Enum value) { + return value.name().toLowerCase(Locale.ROOT); + } +} diff --git a/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/sender/BuiltInSecureMailSender.java b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/sender/BuiltInSecureMailSender.java index c9805ff..c7798b4 100644 --- a/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/sender/BuiltInSecureMailSender.java +++ b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/sender/BuiltInSecureMailSender.java @@ -59,12 +59,25 @@ public final class BuiltInSecureMailSender extends AbstractSecureMailSender { 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() ? SendReport.PlaintextReason.NONE : reason(d.key()), d.encrypted() ? null : describe(d.key()))) .toList()); log.debug("Sent {} cop{}: {}", copies.size(), copies.size() == 1 ? "y" : "ies", report); return report; } + private static SendReport.PlaintextReason reason(RecipientKey key) { + return switch (key) { + case RecipientKey.Usable ignored -> SendReport.PlaintextReason.NONE; + case RecipientKey.Missing missing -> switch (missing.reason()) { + case NOT_FOUND -> SendReport.PlaintextReason.NOT_FOUND; + case UNREACHABLE -> SendReport.PlaintextReason.UNREACHABLE; + case REJECTED -> SendReport.PlaintextReason.REJECTED; + }; + case RecipientKey.NotLookedUp ignored -> SendReport.PlaintextReason.PLAINTEXT_POLICY; + }; + } + private static String describe(RecipientKey key) { return switch (key) { case RecipientKey.Usable usable -> "key from " + usable.source(); diff --git a/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/sender/RelaySecureMailSender.java b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/sender/RelaySecureMailSender.java index d20aa9f..a896ccf 100644 --- a/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/sender/RelaySecureMailSender.java +++ b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/sender/RelaySecureMailSender.java @@ -65,7 +65,8 @@ public final class RelaySecureMailSender extends AbstractSecureMailSender { RecipientType type) { for (InternetAddress address : addresses) { outcomes.add(new SendReport.RecipientOutcome(address.getAddress(), type, - policies.resolve(address.getAddress()), SendReport.Outcome.RELAYED, null)); + policies.resolve(address.getAddress()), SendReport.Outcome.RELAYED, SendReport.PlaintextReason.NONE, + null)); } } } diff --git a/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/sender/SendReport.java b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/sender/SendReport.java index 3789548..399fc8f 100644 --- a/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/sender/SendReport.java +++ b/mailsender-pgp-spring-boot-autoconfigure/src/main/java/pro/thinhha/mailsender/pgp/sender/SendReport.java @@ -17,10 +17,24 @@ public record SendReport(List recipients) { } /** - * @param detail why the recipient got plaintext (missing key reason), or {@code null} + * @param reason why the recipient got plaintext; {@link PlaintextReason#NONE} otherwise + * @param detail human-readable detail for the reason, or {@code null} */ public record RecipientOutcome(String address, RecipientType type, EncryptionPolicy policy, Outcome outcome, - String detail) { + PlaintextReason reason, String detail) { + } + + public enum PlaintextReason { + /** Not sent in plaintext by this application (encrypted, or relayed). */ + NONE, + /** The recipient's policy is PLAINTEXT. */ + PLAINTEXT_POLICY, + /** No key published. */ + NOT_FOUND, + /** The key lookup failed. */ + UNREACHABLE, + /** Keys were found but none was usable. */ + REJECTED } public enum Outcome { 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 6bf1061..51e17c2 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,3 @@ pro.thinhha.mailsender.pgp.autoconfigure.PgpMailAutoConfiguration +pro.thinhha.mailsender.pgp.autoconfigure.PgpMailMetricsAutoConfiguration +pro.thinhha.mailsender.pgp.autoconfigure.PgpMailEndpointAutoConfiguration diff --git a/mailsender-pgp-spring-boot-autoconfigure/src/test/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpKeysEndpointWebTest.java b/mailsender-pgp-spring-boot-autoconfigure/src/test/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpKeysEndpointWebTest.java new file mode 100644 index 0000000..0f8b4f8 --- /dev/null +++ b/mailsender-pgp-spring-boot-autoconfigure/src/test/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpKeysEndpointWebTest.java @@ -0,0 +1,56 @@ +package pro.thinhha.mailsender.pgp.autoconfigure; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.file.Files; +import java.nio.file.Path; + +import org.junit.jupiter.api.Test; +import org.pgpainless.PGPainless; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.HttpStatus; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.web.client.RestClient; + +/** Addresses contain '@' and '.', so check the selector survives real HTTP path matching. */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = { + "spring.mail.host=localhost", + "mail.pgp.wkd.enabled=false", + "management.endpoints.web.exposure.include=pgpkeys" +}) +class PgpKeysEndpointWebTest { + + @SpringBootConfiguration + @EnableAutoConfiguration + static class App { + } + + @DynamicPropertySource + static void keys(DynamicPropertyRegistry registry) throws Exception { + Path key = Files.createTempFile("bob", ".asc"); + key.toFile().deleteOnExit(); + Files.writeString(key, PGPainless.getInstance().generateKey() + .modernKeyRing("Bob ").toCertificate().toAsciiArmoredString()); + registry.add("mail.pgp.static-keys[bob@legacy.org]", () -> "file:" + key); + } + + @LocalServerPort + int port; + + @Test + void readsAndEvictsByAddress() { + RestClient client = RestClient.create("http://localhost:" + port + "/actuator/pgpkeys"); + + String body = client.get().uri("/bob@legacy.org").retrieve().body(String.class); + assertThat(body).contains("\"address\":\"bob@legacy.org\"", "\"lookup\":\"found:STATIC\"", + "\"wouldEncrypt\":true"); + + assertThat(client.delete().uri("/bob@legacy.org").retrieve().toBodilessEntity().getStatusCode()) + .isEqualTo(HttpStatus.NO_CONTENT); + assertThat(client.get().retrieve().body(String.class)).contains("\"cachedAddresses\""); + } +} diff --git a/mailsender-pgp-spring-boot-autoconfigure/src/test/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailObservabilityAutoConfigurationTest.java b/mailsender-pgp-spring-boot-autoconfigure/src/test/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailObservabilityAutoConfigurationTest.java new file mode 100644 index 0000000..8af9ba9 --- /dev/null +++ b/mailsender-pgp-spring-boot-autoconfigure/src/test/java/pro/thinhha/mailsender/pgp/autoconfigure/PgpMailObservabilityAutoConfigurationTest.java @@ -0,0 +1,94 @@ +package pro.thinhha.mailsender.pgp.autoconfigure; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.file.Files; +import java.nio.file.Path; + +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.FilteredClassLoader; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import pro.thinhha.mailsender.pgp.delivery.EncryptionPolicy; +import pro.thinhha.mailsender.pgp.metrics.MeteredSecureMailSender; +import pro.thinhha.mailsender.pgp.sender.BuiltInSecureMailSender; +import pro.thinhha.mailsender.pgp.sender.SecureMailSender; + +class PgpMailObservabilityAutoConfigurationTest { + + private final ApplicationContextRunner runner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(MailSenderAutoConfiguration.class, + PgpMailAutoConfiguration.class, PgpMailMetricsAutoConfiguration.class, + PgpMailEndpointAutoConfiguration.class)) + .withPropertyValues("spring.mail.host=localhost"); + + @TempDir + Path tempDir; + + @Test + void wrapsSenderWithMetricsWhenRegistryPresent() { + runner.withBean(MeterRegistry.class, SimpleMeterRegistry::new) + .run(context -> assertThat(context.getBean(SecureMailSender.class)) + .isInstanceOf(MeteredSecureMailSender.class)); + } + + @Test + void leavesSenderAloneWithoutRegistry() { + runner.run(context -> assertThat(context.getBean(SecureMailSender.class)) + .isInstanceOf(BuiltInSecureMailSender.class)); + } + + @Test + void worksWithoutMicrometerAndActuatorOnClasspath() { + runner.withClassLoader(new FilteredClassLoader("io.micrometer", "org.springframework.boot.actuate")) + .run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context.getBean(SecureMailSender.class)).isInstanceOf(BuiltInSecureMailSender.class); + }); + } + + @Test + void endpointOnlyWhenExposed() { + runner.run(context -> assertThat(context).doesNotHaveBean(PgpKeysEndpoint.class)); + } + + @Test + void endpointReportsPolicyKeyAndValidity() throws Exception { + Path key = tempDir.resolve("bob.asc"); + Files.writeString(key, PGPainless.getInstance().generateKey() + .modernKeyRing("Bob ").toCertificate().toAsciiArmoredString()); + + runner.withPropertyValues( + "management.endpoints.web.exposure.include=pgpkeys", + "mail.pgp.wkd.enabled=false", + "mail.pgp.recipients[*@legacy.org]=REQUIRE", + "mail.pgp.static-keys[bob@legacy.org]=file:" + key) + .run(context -> { + PgpKeysEndpoint endpoint = context.getBean(PgpKeysEndpoint.class); + + PgpKeysEndpoint.AddressReport bob = endpoint.address("bob@legacy.org"); + assertThat(bob.policy()).isEqualTo(EncryptionPolicy.REQUIRE); + assertThat(bob.lookup()).isEqualTo("found:STATIC"); + assertThat(bob.wouldEncrypt()).isTrue(); + assertThat(bob.keys()).singleElement().satisfies(k -> { + assertThat(k.valid()).isTrue(); + assertThat(k.userIds()).containsExactly("Bob "); + assertThat(k.fingerprint()).hasSize(40); + }); + + PgpKeysEndpoint.AddressReport carol = endpoint.address("carol@example.org"); + assertThat(carol.lookup()).isEqualTo("not_found"); + assertThat(carol.wouldEncrypt()).isFalse(); + + assertThat(endpoint.summary()).containsEntry("wkd", false); + endpoint.evict("bob@legacy.org"); + endpoint.evictAll(); + }); + } +} diff --git a/mailsender-pgp-spring-boot-autoconfigure/src/test/java/pro/thinhha/mailsender/pgp/metrics/MeteredSecureMailSenderTest.java b/mailsender-pgp-spring-boot-autoconfigure/src/test/java/pro/thinhha/mailsender/pgp/metrics/MeteredSecureMailSenderTest.java new file mode 100644 index 0000000..b12d55b --- /dev/null +++ b/mailsender-pgp-spring-boot-autoconfigure/src/test/java/pro/thinhha/mailsender/pgp/metrics/MeteredSecureMailSenderTest.java @@ -0,0 +1,88 @@ +package pro.thinhha.mailsender.pgp.metrics; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.springframework.mail.SimpleMailMessage; + +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import jakarta.mail.internet.MimeMessage; +import pro.thinhha.mailsender.pgp.delivery.EncryptionPolicy; +import pro.thinhha.mailsender.pgp.delivery.EncryptionPolicyException; +import pro.thinhha.mailsender.pgp.delivery.RecipientType; +import pro.thinhha.mailsender.pgp.discovery.DiscoveryResult; +import pro.thinhha.mailsender.pgp.sender.SecureMailSender; +import pro.thinhha.mailsender.pgp.sender.SendReport; +import pro.thinhha.mailsender.pgp.sender.SendReport.Outcome; +import pro.thinhha.mailsender.pgp.sender.SendReport.PlaintextReason; + +class MeteredSecureMailSenderTest { + + private final SimpleMeterRegistry registry = new SimpleMeterRegistry(); + + @Test + void countsRecipientsByOutcomePolicyAndReason() { + SendReport report = new SendReport(List.of( + outcome("a@example.org", Outcome.ENCRYPTED, PlaintextReason.NONE), + outcome("b@example.org", Outcome.ENCRYPTED, PlaintextReason.NONE), + outcome("c@example.org", Outcome.PLAINTEXT, PlaintextReason.NOT_FOUND))); + + new MeteredSecureMailSender(sender(report, null), registry).send(new SimpleMailMessage()); + + assertThat(count("encrypted", "none")).isEqualTo(2); + assertThat(count("plaintext", "not_found")).isEqualTo(1); + } + + @Test + void countsRefusals() { + SecureMailSender failing = sender(null, new EncryptionPolicyException("no key", List.of("x@y.z"))); + + assertThatThrownBy(() -> new MeteredSecureMailSender(failing, registry).send(new SimpleMailMessage())) + .isInstanceOf(EncryptionPolicyException.class); + assertThat(registry.get(MeteredSecureMailSender.REFUSED).counter().count()).isEqualTo(1); + } + + @Test + void timesKeyLookupsWithResultAndSource() { + new MeteredKeyDiscovery(email -> DiscoveryResult.notFound(), registry).discover("a@example.org"); + + assertThat(registry.get(MeteredKeyDiscovery.LOOKUP).tag("result", "not_found").tag("source", "none") + .timer().count()).isEqualTo(1); + } + + private double count(String outcome, String reason) { + return registry.get(MeteredSecureMailSender.RECIPIENTS) + .tag("outcome", outcome).tag("policy", "opportunistic").tag("reason", reason) + .counter().count(); + } + + private static SendReport.RecipientOutcome outcome(String address, Outcome outcome, PlaintextReason reason) { + return new SendReport.RecipientOutcome(address, RecipientType.TO, EncryptionPolicy.OPPORTUNISTIC, outcome, + reason, null); + } + + private static SecureMailSender sender(SendReport report, RuntimeException failure) { + return new SecureMailSender() { + @Override + public MimeMessage createMimeMessage() { + throw new UnsupportedOperationException(); + } + + @Override + public SendReport send(MimeMessage message) { + return send((SimpleMailMessage) null); + } + + @Override + public SendReport send(SimpleMailMessage message) { + if (failure != null) { + throw failure; + } + return report; + } + }; + } +} From da7fd378933a6143fe17fe420e80ce4e5d8fa302 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 03:39:22 +0000 Subject: [PATCH 5/5] Add GitHub Actions CI running mvn verify on JDK 27 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G71TQ5jDETVwdUJxGAwaiT --- .github/workflows/ci.yml | 31 +++++++++++++++++++++++++++++++ docs/PLAN.md | 2 +- 2 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..eca744e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,31 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '27' + cache: maven + + - name: Build and test + run: mvn -B -ntp verify + + - name: Upload test reports + if: failure() + uses: actions/upload-artifact@v4 + with: + name: surefire-reports + path: '**/target/surefire-reports/' diff --git a/docs/PLAN.md b/docs/PLAN.md index 487549d..955d355 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -173,7 +173,7 @@ Actuator is optional; when the `pgpkeys` endpoint is exposed Extension points: `KeyDiscoveryCustomizer` (wraps WKD discovery below the cache) and `SecureMailSenderCustomizer`. ## Milestones -1. **Skeleton** — parent POM, modules, CI. ✅ +1. **Skeleton** — parent POM, modules, CI (GitHub Actions, `mvn verify` on Temurin 27). ✅ 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. ✅