mirror of
https://github.com/sneakybugmaster/mailsender.git
synced 2026-11-10 19:48:49 +00:00
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:
35 files changed
+1885
No files matched your search
@@ -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>
|
||||
+121
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+23
@@ -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);
|
||||
}
|
||||
}
|
||||
+34
@@ -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();
|
||||
}
|
||||
}
|
||||
+36
@@ -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();
|
||||
}
|
||||
}
|
||||
+11
@@ -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
|
||||
}
|
||||
+22
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
+64
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+19
@@ -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);
|
||||
}
|
||||
+34
@@ -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);
|
||||
}
|
||||
}
|
||||
+15
@@ -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
|
||||
}
|
||||
+21
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+96
@@ -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;
|
||||
}
|
||||
}
|
||||
+109
@@ -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);
|
||||
}
|
||||
}
|
||||
+44
@@ -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);
|
||||
}
|
||||
}
|
||||
+85
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+37
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+21
@@ -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");
|
||||
}
|
||||
}
|
||||
+160
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user