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

+ */ +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,) + + + + + + + + +