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

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