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,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>
|
||||
+89
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+52
@@ -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) {
|
||||
}
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
io.github.sneakybugmaster.mailsender.pgp.autoconfigure.PgpMailAutoConfiguration
|
||||
+75
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user