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

+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));
}
}
}