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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G71TQ5jDETVwdUJxGAwaiT
This commit is contained in:
Claude committed 2026-10-12 03:13:23 +00:00
1 parent 7701ffe034
commit 213df4c34e
35 files changed
+1885

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
target/
.idea/
*.iml
.vscode/
.DS_Store
+43
View File
@@ -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 |
+175
View File
@@ -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=<localpart>`).
- Advanced method first. Fall back to direct per the WKD draft only when the `openpgpkey.<domain>` 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.
+54
View File
@@ -0,0 +1,54 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>io.github.sneakybugmaster.mailsender</groupId>
<artifactId>mailsender-parent</artifactId>
<version>0.1.0-SNAPSHOT</version>
</parent>
<artifactId>mailsender-demo</artifactId>
<description>Demo app: looks up the recipient's WKD key and sends test e-mails</description>
<dependencies>
<dependency>
<groupId>io.github.sneakybugmaster.mailsender</groupId>
<artifactId>mailsender-pgp-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.icegreen</groupId>
<artifactId>greenmail-junit5</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>${spring-boot.version}</version>
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
@@ -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);
}
}
@@ -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) {
}
@@ -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.
* <p>
* 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() + ")";
};
}
}
@@ -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
@@ -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");
}
}
+50
View File
@@ -0,0 +1,50 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>io.github.sneakybugmaster.mailsender</groupId>
<artifactId>mailsender-parent</artifactId>
<version>0.1.0-SNAPSHOT</version>
</parent>
<artifactId>mailsender-pgp-core</artifactId>
<description>WKD key discovery, validation, OpenPGP encryption and PGP/MIME building (no Spring)</description>
<dependencies>
<dependency>
<groupId>org.pgpainless</groupId>
<artifactId>pgpainless-core</artifactId>
</dependency>
<dependency>
<groupId>org.pgpainless</groupId>
<artifactId>wkd-java</artifactId>
</dependency>
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-simple</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
@@ -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:
* <ul>
* <li>{@link DiscoveryResult.Found}: {@code ttl}, capped at the earliest certificate expiry</li>
* <li>{@link DiscoveryResult.NotFound}: {@code negativeTtl}</li>
* <li>{@link DiscoveryResult.Unreachable}: {@code errorTtl}</li>
* </ul>
*/
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<String, DiscoveryResult> 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<String, DiscoveryResult> {
@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;
}
}
}
@@ -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<OpenPGPCertificate> parse(byte[] data) throws IOException {
return PGPainless.getInstance().readKey().parseCertificates(data);
}
}
@@ -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<KeyDiscovery> delegates;
public CompositeKeyDiscovery(List<? extends KeyDiscovery> 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();
}
}
@@ -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<OpenPGPCertificate> 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();
}
}
@@ -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.<domain>}). */
WKD_ADVANCED,
/** WKD direct method ({@code <domain>/.well-known/openpgpkey}). */
WKD_DIRECT
}
@@ -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;
}
};
}
}
@@ -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<InputStream> 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);
}
}
}
@@ -0,0 +1,19 @@
package io.github.sneakybugmaster.mailsender.pgp.discovery;
/**
* Finds the OpenPGP certificate(s) published for an e-mail address.
* <p>
* 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 <em>not</em> 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);
}
@@ -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<String, List<OpenPGPCertificate>> certificates;
public StaticKeyDiscovery(Map<String, List<OpenPGPCertificate>> certificates) {
Map<String, List<OpenPGPCertificate>> 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<OpenPGPCertificate> 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);
}
}
@@ -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.<domain>} 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
}
@@ -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;
}
}
}
@@ -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=<localpart>}).
*/
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<OpenPGPCertificate> 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;
}
}
@@ -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 <alice@example.org>")));
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 <alice@example.org>", 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);
}
}
@@ -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 <bob@legacy.org>");
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);
}
}
@@ -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<String> 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);
}
}
}
@@ -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);
}
}
}
@@ -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");
}
}
@@ -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 <alice@example.org>");
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, "<html>not a key</html>".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<String, Response> responses = new HashMap<>();
private final Map<String, IOException> failures = new HashMap<>();
final List<URI> 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<String> 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));
}
}
}
@@ -0,0 +1,54 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>io.github.sneakybugmaster.mailsender</groupId>
<artifactId>mailsender-parent</artifactId>
<version>0.1.0-SNAPSHOT</version>
</parent>
<artifactId>mailsender-pgp-spring-boot-autoconfigure</artifactId>
<dependencies>
<dependency>
<groupId>io.github.sneakybugmaster.mailsender</groupId>
<artifactId>mailsender-pgp-core</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-autoconfigure</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<version>${spring-boot.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>
</project>
@@ -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<String, List<OpenPGPCertificate>> 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> 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<CachingKeyDiscovery> wkd) {
List<KeyDiscovery> chain = new ArrayList<>();
chain.add(staticKeys);
wkd.ifAvailable(chain::add);
return new CompositeKeyDiscovery(chain);
}
private static List<OpenPGPCertificate> read(String email, Resource resource) {
try (InputStream in = resource.getInputStream()) {
List<OpenPGPCertificate> 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);
}
}
}
@@ -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.
* <p>
* 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<String, Resource> 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) {
}
}
@@ -0,0 +1 @@
io.github.sneakybugmaster.mailsender.pgp.autoconfigure.PgpMailAutoConfiguration
@@ -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 <bob@legacy.org>").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));
}
}
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>io.github.sneakybugmaster.mailsender</groupId>
<artifactId>mailsender-parent</artifactId>
<version>0.1.0-SNAPSHOT</version>
</parent>
<artifactId>mailsender-pgp-spring-boot-starter</artifactId>
<description>Starter: OpenPGP-encrypted mail for Spring Boot</description>
<dependencies>
<dependency>
<groupId>io.github.sneakybugmaster.mailsender</groupId>
<artifactId>mailsender-pgp-spring-boot-autoconfigure</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
</dependencies>
</project>
+121
View File
@@ -0,0 +1,121 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>io.github.sneakybugmaster.mailsender</groupId>
<artifactId>mailsender-parent</artifactId>
<version>0.1.0-SNAPSHOT</version>
<packaging>pom</packaging>
<name>mailsender</name>
<description>OpenPGP (WKD + PGP/MIME) encryption for Spring Boot mail</description>
<modules>
<module>mailsender-pgp-core</module>
<module>mailsender-pgp-spring-boot-autoconfigure</module>
<module>mailsender-pgp-spring-boot-starter</module>
<module>mailsender-demo</module>
</modules>
<properties>
<maven.compiler.release>27</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<spring-boot.version>4.1.1</spring-boot.version>
<pgpainless.version>2.0.4</pgpainless.version>
<wkd-java.version>0.1.3</wkd-java.version>
<caffeine.version>3.3.0</caffeine.version>
<greenmail.version>2.1.14</greenmail.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring-boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>io.github.sneakybugmaster.mailsender</groupId>
<artifactId>mailsender-pgp-core</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>io.github.sneakybugmaster.mailsender</groupId>
<artifactId>mailsender-pgp-spring-boot-autoconfigure</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>io.github.sneakybugmaster.mailsender</groupId>
<artifactId>mailsender-pgp-spring-boot-starter</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.pgpainless</groupId>
<artifactId>pgpainless-core</artifactId>
<version>${pgpainless.version}</version>
</dependency>
<dependency>
<groupId>org.pgpainless</groupId>
<artifactId>wkd-java</artifactId>
<version>${wkd-java.version}</version>
</dependency>
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
<version>${caffeine.version}</version>
</dependency>
<dependency>
<groupId>com.icegreen</groupId>
<artifactId>greenmail-junit5</artifactId>
<version>${greenmail.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
<build>
<pluginManagement>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.14.0</version>
<configuration>
<parameters>true</parameters>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.5.3</version>
</plugin>
</plugins>
</pluginManagement>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-enforcer-plugin</artifactId>
<version>3.5.0</version>
<executions>
<execution>
<id>enforce-java</id>
<goals>
<goal>enforce</goal>
</goals>
<configuration>
<rules>
<requireJavaVersion>
<version>[27,)</version>
</requireJavaVersion>
</rules>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>