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,5 @@
|
||||
target/
|
||||
.idea/
|
||||
*.iml
|
||||
.vscode/
|
||||
.DS_Store
|
||||
@@ -0,0 +1,43 @@
|
||||
# mailsender
|
||||
|
||||
OpenPGP encryption for Spring Boot mail: discovers recipients' keys via Web Key Directory (WKD) and sends
|
||||
PGP/MIME (RFC 3156), or hands mail to an encrypting relay. See [docs/PLAN.md](docs/PLAN.md) for the design
|
||||
and milestone status.
|
||||
|
||||
Requires JDK 27 and Maven 3.9+. Built on Spring Boot 4.1.1 and PGPainless 2.0.
|
||||
|
||||
```
|
||||
mvn verify
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
| Module | Contents |
|
||||
|---|---|
|
||||
| `mailsender-pgp-core` | WKD key discovery, caching, pinned keys (no Spring) |
|
||||
| `mailsender-pgp-spring-boot-autoconfigure` | `mail.pgp.*` properties and discovery beans |
|
||||
| `mailsender-pgp-spring-boot-starter` | dependency bundle for applications |
|
||||
| `mailsender-demo` | sample app that sends test mails |
|
||||
|
||||
## Demo app
|
||||
|
||||
The demo app looks up the recipient's key via WKD, logs the result, and sends `DEMO_COUNT` test mails
|
||||
(default: 1 to `test@thinhha.pro`). Encryption is not wired in yet, so the mails are sent **in plaintext**,
|
||||
and the body says whether a key was found.
|
||||
|
||||
```
|
||||
mvn -q install -DskipTests
|
||||
SMTP_HOST=smtp.example.com SMTP_PORT=587 \
|
||||
SMTP_USERNAME=me@example.com SMTP_PASSWORD='app-password' \
|
||||
DEMO_TO=test@thinhha.pro DEMO_COUNT=1 \
|
||||
java -jar mailsender-demo/target/mailsender-demo-0.1.0-SNAPSHOT.jar
|
||||
```
|
||||
|
||||
| Variable | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `SMTP_HOST` / `SMTP_PORT` | `localhost` / `587` | SMTP server |
|
||||
| `SMTP_USERNAME` / `SMTP_PASSWORD` | empty | SMTP credentials |
|
||||
| `SMTP_AUTH` / `SMTP_STARTTLS` | `true` / `true` | set to `false` for a local test server |
|
||||
| `DEMO_TO` | `test@thinhha.pro` | recipient |
|
||||
| `DEMO_FROM` | `SMTP_USERNAME` | sender address |
|
||||
| `DEMO_COUNT` | `1` | number of mails |
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
# mailsender — OpenPGP (WKD + PGP/MIME) for Spring Boot
|
||||
|
||||
A Spring Boot starter that lets an application keep using `JavaMailSender`-style sending while
|
||||
mail to recipients with a published OpenPGP key goes out encrypted. The application can either
|
||||
encrypt in-process ("built-in") or hand mail to an encrypting relay/gateway.
|
||||
|
||||
## Decisions
|
||||
|
||||
| Topic | Decision |
|
||||
|---|---|
|
||||
| Build | Maven, multi-module |
|
||||
| Java | JDK 27 (`maven.compiler.release=27`) |
|
||||
| Spring Boot | 4.1.1 (imported as BOM, not as parent, so consumers are not forced onto our parent) |
|
||||
| Crypto | PGPainless 2.0.x (on Bouncy Castle 1.84, `OpenPGPCertificate` API) |
|
||||
| WKD hashing/URLs | `org.pgpainless:wkd-java` 0.1.3 (`WKDAddress`). Note: there is no `pgpainless-wkd` artifact on Maven Central; `wkd-java` provides the hashing/URL building. Fetching is ours (JDK `HttpClient`) so we control timeouts, size limits, redirects and fallback. |
|
||||
| Default policy | `OPPORTUNISTIC` |
|
||||
| Mixed recipients | `SPLIT` |
|
||||
| Readable sent copies | Only if the client configures its own public key for the sender address. No key → sent copy is not decryptable by the sender (logged once at INFO). |
|
||||
| Encryption mode | Client chooses `BUILT_IN` or `RELAY` via `mail.pgp.mode`. A custom `SecureMailSender` bean replaces both. |
|
||||
|
||||
## Modules
|
||||
|
||||
```
|
||||
mailsender/
|
||||
├── pom.xml parent; imports spring-boot-dependencies 4.1.1
|
||||
├── mailsender-pgp-core/ plain Java, no Spring
|
||||
│ discovery/ KeyDiscovery, DiscoveryResult, WkdKeyDiscovery, WkdFetcher,
|
||||
│ HttpWkdFetcher, HostResolver, CachingKeyDiscovery,
|
||||
│ StaticKeyDiscovery, CompositeKeyDiscovery
|
||||
│ validation/ CertificateValidator, ValidationResult (M3)
|
||||
│ crypto/ PgpEncryptor (M4)
|
||||
│ mime/ PgpMimeBuilder, EncryptedMultipart, EnvelopeMimeMessage (M4)
|
||||
│ policy/ EncryptionPolicy, PolicyResolver, RecipientPlan,
|
||||
│ DeliveryPlanner (M5)
|
||||
├── mailsender-pgp-spring-boot-autoconfigure/
|
||||
│ PgpMailProperties, PgpMailAutoConfiguration,
|
||||
│ SecureMailSender (+ BuiltInSecureMailSender, RelaySecureMailSender) (M6)
|
||||
├── mailsender-pgp-spring-boot-starter/ dependencies only
|
||||
└── mailsender-demo/ sample app + GreenMail E2E (M7)
|
||||
```
|
||||
|
||||
## Pipeline (BUILT_IN mode)
|
||||
|
||||
```
|
||||
SecureMailSender.send(message)
|
||||
└─ collect To/Cc/Bcc
|
||||
└─ for each recipient: PolicyResolver → policy; KeyDiscovery (cached) → cert?; CertificateValidator
|
||||
└─ DeliveryPlanner → 1..n deliveries:
|
||||
encrypted group (To/Cc with valid keys) → 1 encrypted message
|
||||
plaintext group (OPPORTUNISTIC/PLAINTEXT, no key) → 1 plaintext message (WARN + metric)
|
||||
each Bcc recipient → own copy (encrypted if key)
|
||||
any REQUIRE recipient without key → EncryptionRequiredException, nothing sent
|
||||
└─ per encrypted delivery: inner MIME → canonical CRLF bytes → encrypt to recipients
|
||||
(+ sender's self key if configured) → PGP/MIME (RFC 3156) wrapper
|
||||
└─ JavaMailSender.send(EnvelopeMimeMessage) — returns SendReport
|
||||
```
|
||||
|
||||
### Key discovery (M2)
|
||||
- `WKDAddress.fromEmail(addr)` builds both URLs (lowercased local part → SHA-1 → z-base-32, `?l=<localpart>`).
|
||||
- Advanced method first. Fall back to direct per the WKD draft only when the `openpgpkey.<domain>` host
|
||||
does not resolve (`fallback: ON_DNS_FAILURE`, default). `ON_ANY_FAILURE` is available for the many
|
||||
domains with wildcard DNS whose advanced host answers 404 or has a broken TLS cert.
|
||||
- HTTP: HTTPS only, no redirects, connect/read timeouts, max response size.
|
||||
- Result is a sealed type: `Found(certs, method, uri)`, `NotFound`, `Unreachable(reason)`.
|
||||
The policy layer treats `Unreachable` like `NotFound` for OPPORTUNISTIC recipients but logs it
|
||||
separately so outages are visible.
|
||||
- `CachingKeyDiscovery` (Caffeine): positive TTL (24h, capped at certificate expiry), negative TTL (1h),
|
||||
error TTL (5m). An entry can be evicted by address (key rotation).
|
||||
- `StaticKeyDiscovery`: client-pinned keys, checked before WKD via `CompositeKeyDiscovery`.
|
||||
|
||||
### Validation (M3)
|
||||
A certificate is usable for `addr` only if: not revoked/expired; has a valid encryption-capable
|
||||
(communications) subkey; has a valid UID containing `addr` (domain case-insensitive); meets the
|
||||
algorithm policy (e.g. RSA ≥ 2048). Rejections carry a reason for logs/metrics.
|
||||
|
||||
### Encryption + PGP/MIME (M4)
|
||||
- PGPainless `generateMessage()` with `EncryptionOptions.encryptCommunications()`, ASCII armor on.
|
||||
- Inner entity = the original body (text/html/attachments) with its own `Content-Type` headers,
|
||||
serialized with `writeTo()`, CRLF-canonicalized, then encrypted.
|
||||
- Outer entity: `multipart/encrypted; protocol="application/pgp-encrypted"` with
|
||||
part 1 `application/pgp-encrypted` = `Version: 1`, part 2 `application/octet-stream; name="encrypted.asc"`.
|
||||
- `EncryptedMultipart` subclasses `MimeMultipart` to set the content type itself, otherwise
|
||||
`saveChanges()` drops the `protocol` parameter.
|
||||
- Protected headers (RFC 9788) opt-in: `mail.pgp.protected-headers=true` moves the real Subject inside and
|
||||
sets the outer one to `...`.
|
||||
|
||||
### Split delivery + envelope (M5)
|
||||
`SPLIT` copies keep the original `To`/`Cc` headers so every recipient sees the full conversation, but each
|
||||
copy must only be delivered to its subgroup. `JavaMailSenderImpl` uses `MimeMessage.getAllRecipients()`
|
||||
as the SMTP envelope, so `EnvelopeMimeMessage` overrides that method to return only the subgroup.
|
||||
Bcc recipients always get their own copy (avoids leaking them in headers or key IDs).
|
||||
|
||||
### Sent copies (encrypt-to-self)
|
||||
```yaml
|
||||
mail.pgp.sender-keys:
|
||||
"[noreply@acme.com]": classpath:keys/noreply.pub.asc
|
||||
"[billing@acme.com]": file:/etc/mail/billing.pub.asc
|
||||
```
|
||||
Or a `SenderKeyProvider` bean. Keys are validated at startup (fail fast if unusable). If the `From`
|
||||
address has no configured key, nothing is added and the sent copy cannot be decrypted by the sender.
|
||||
|
||||
## RELAY mode (M6)
|
||||
|
||||
The application sends plaintext to an encrypting gateway (e.g. a GnuPG-based MTA filter using
|
||||
`--auto-key-locate wkd`, or a commercial mail gateway); key discovery and encryption happen there.
|
||||
|
||||
```yaml
|
||||
mail.pgp:
|
||||
mode: RELAY
|
||||
relay:
|
||||
host: gateway.internal
|
||||
port: 587
|
||||
username: app
|
||||
password: ${RELAY_PASSWORD}
|
||||
starttls: required # the hop to the gateway carries plaintext — must be TLS
|
||||
policy-header: X-Encryption-Policy # optional; per-recipient policy hint for the gateway
|
||||
```
|
||||
- `RelaySecureMailSender` uses its own `JavaMailSender` pointed at the relay (the app's normal
|
||||
`spring.mail.*` sender is untouched).
|
||||
- REQUIRE/OPPORTUNISTIC cannot be enforced by the library in this mode; they are passed as a header hint
|
||||
and enforcement is the gateway's responsibility. Documented and logged at startup.
|
||||
- Encrypt-to-self is the gateway's responsibility in this mode.
|
||||
|
||||
## Configuration (full shape)
|
||||
|
||||
Map keys that are addresses must be written in brackets (`"[bob@legacy.org]"`); otherwise Spring's relaxed
|
||||
binding strips the `@` and `.` characters.
|
||||
|
||||
```yaml
|
||||
mail.pgp:
|
||||
enabled: true
|
||||
mode: BUILT_IN # BUILT_IN | RELAY
|
||||
default-policy: OPPORTUNISTIC # REQUIRE | OPPORTUNISTIC | PLAINTEXT
|
||||
mixed-mode: SPLIT # SPLIT | FAIL
|
||||
protected-headers: false
|
||||
recipients: # exact address or *@domain
|
||||
"[ceo@example.org]": REQUIRE
|
||||
"[*@partner.com]": REQUIRE
|
||||
"[*@newsletter.example]": PLAINTEXT
|
||||
static-keys:
|
||||
"[bob@legacy.org]": file:/etc/keys/bob.asc
|
||||
sender-keys: {}
|
||||
wkd:
|
||||
enabled: true
|
||||
fallback: ON_DNS_FAILURE # ON_DNS_FAILURE | ON_ANY_FAILURE
|
||||
connect-timeout: 3s
|
||||
read-timeout: 5s
|
||||
max-response-size: 256KB
|
||||
cache:
|
||||
ttl: 24h
|
||||
negative-ttl: 1h
|
||||
error-ttl: 5m
|
||||
max-size: 10000
|
||||
relay: { ... } # see RELAY mode
|
||||
```
|
||||
|
||||
## Observability
|
||||
- `mail.pgp.sent{mode=encrypted|plaintext}`, `mail.pgp.downgrade{reason=no_key|unreachable|invalid_key}`
|
||||
- `mail.pgp.wkd.lookup{result=found|not_found|unreachable, method=advanced|direct}`
|
||||
- Caffeine cache metrics; actuator endpoint `pgpkeys` to inspect/evict a cached address.
|
||||
|
||||
## Milestones
|
||||
1. **Skeleton** — parent POM, modules, CI. ✅
|
||||
2. **WKD discovery + cache** — `WkdKeyDiscovery`, `HttpWkdFetcher`, `CachingKeyDiscovery`, static/composite discovery,
|
||||
properties + autoconfig for discovery beans. Tests: z-base-32 vector, advanced/direct fallback matrix,
|
||||
size limit, no redirects, cache TTLs. ✅
|
||||
3. **Validation** — generated test keys: expired, revoked, sign-only, wrong UID, valid.
|
||||
4. **Encryptor + PGP/MIME** — round-trip decrypt test, golden structure test, CRLF canonicalization.
|
||||
5. **Policy + delivery planner** — table-driven tests over key/no-key × REQUIRE/OPPORTUNISTIC/PLAINTEXT × To/Cc/Bcc;
|
||||
`EnvelopeMimeMessage`.
|
||||
6. **SecureMailSender (BUILT_IN + RELAY)**, `SendReport`, sender keys, metrics, autoconfig switch.
|
||||
7. **Demo + E2E** with GreenMail; manual check against a real WKD domain, decrypt in Thunderbird.
|
||||
Demo app scaffolded early (sends plaintext test mails + reports WKD result) ✅; verified live:
|
||||
`test@thinhha.pro` → found via advanced method, fpr `ADA6EFA0CC1630D602889820112495670FF1B4F0`.
|
||||
8. **Hardening** — protected headers, actuator endpoint, docs.
|
||||
@@ -0,0 +1,54 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>io.github.sneakybugmaster.mailsender</groupId>
|
||||
<artifactId>mailsender-parent</artifactId>
|
||||
<version>0.1.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>mailsender-demo</artifactId>
|
||||
<description>Demo app: looks up the recipient's WKD key and sends test e-mails</description>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>io.github.sneakybugmaster.mailsender</groupId>
|
||||
<artifactId>mailsender-pgp-spring-boot-starter</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.icegreen</groupId>
|
||||
<artifactId>greenmail-junit5</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
<version>${spring-boot.version}</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<goals>
|
||||
<goal>repackage</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package io.github.sneakybugmaster.mailsender.demo;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
|
||||
|
||||
@SpringBootApplication
|
||||
@ConfigurationPropertiesScan
|
||||
public class DemoApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(DemoApplication.class, args);
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package io.github.sneakybugmaster.mailsender.demo;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.boot.context.properties.bind.DefaultValue;
|
||||
|
||||
/**
|
||||
* @param to recipient of the test mails
|
||||
* @param from sender address; most SMTP providers require it to match the authenticated account
|
||||
* @param count number of test mails to send
|
||||
*/
|
||||
@ConfigurationProperties("demo")
|
||||
public record DemoProperties(
|
||||
@DefaultValue("test@thinhha.pro") String to,
|
||||
String from,
|
||||
@DefaultValue("1") int count) {
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package io.github.sneakybugmaster.mailsender.demo;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.HexFormat;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.ApplicationArguments;
|
||||
import org.springframework.boot.ApplicationRunner;
|
||||
import org.springframework.mail.javamail.JavaMailSender;
|
||||
import org.springframework.mail.javamail.MimeMessageHelper;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import io.github.sneakybugmaster.mailsender.pgp.discovery.DiscoveryResult;
|
||||
import io.github.sneakybugmaster.mailsender.pgp.discovery.KeyDiscovery;
|
||||
import jakarta.mail.MessagingException;
|
||||
import jakarta.mail.internet.MimeMessage;
|
||||
|
||||
/**
|
||||
* Looks up the recipient's key via WKD, then sends {@code demo.count} test mails describing what was found.
|
||||
* <p>
|
||||
* Encryption is not wired in yet (milestones 3–6), so the mails go out in plaintext.
|
||||
*/
|
||||
@Component
|
||||
class TestMailRunner implements ApplicationRunner {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(TestMailRunner.class);
|
||||
|
||||
private final DemoProperties properties;
|
||||
private final KeyDiscovery keyDiscovery;
|
||||
private final JavaMailSender mailSender;
|
||||
|
||||
TestMailRunner(DemoProperties properties, KeyDiscovery keyDiscovery, JavaMailSender mailSender) {
|
||||
this.properties = properties;
|
||||
this.keyDiscovery = keyDiscovery;
|
||||
this.mailSender = mailSender;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run(ApplicationArguments args) throws MessagingException {
|
||||
DiscoveryResult discovery = keyDiscovery.discover(properties.to());
|
||||
String summary = describe(discovery);
|
||||
log.info("Key discovery for {}: {}", properties.to(), summary);
|
||||
|
||||
for (int i = 1; i <= properties.count(); i++) {
|
||||
MimeMessage message = mailSender.createMimeMessage();
|
||||
MimeMessageHelper helper = new MimeMessageHelper(message, "UTF-8");
|
||||
if (properties.from() != null) {
|
||||
helper.setFrom(properties.from());
|
||||
}
|
||||
helper.setTo(properties.to());
|
||||
helper.setSubject("mailsender test %d/%d".formatted(i, properties.count()));
|
||||
helper.setText("""
|
||||
Test mail %d of %d from the mailsender demo, sent %s.
|
||||
|
||||
Key discovery for %s: %s
|
||||
|
||||
This message was sent in plaintext: PGP/MIME encryption is not implemented yet.
|
||||
""".formatted(i, properties.count(), OffsetDateTime.now(), properties.to(), summary));
|
||||
mailSender.send(message);
|
||||
log.info("Sent test mail {}/{} to {}", i, properties.count(), properties.to());
|
||||
}
|
||||
}
|
||||
|
||||
private static String describe(DiscoveryResult result) {
|
||||
return switch (result) {
|
||||
case DiscoveryResult.Found found -> "found via " + found.source() + ", fingerprint(s) "
|
||||
+ found.certificates().stream()
|
||||
.map(c -> HexFormat.of().withUpperCase().formatHex(c.getFingerprint()))
|
||||
.collect(Collectors.joining(", "));
|
||||
case DiscoveryResult.NotFound ignored -> "no key published";
|
||||
case DiscoveryResult.Unreachable u -> "lookup failed (" + u.reason() + ")";
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
spring:
|
||||
main:
|
||||
web-application-type: none
|
||||
mail:
|
||||
host: ${SMTP_HOST:localhost}
|
||||
port: ${SMTP_PORT:587}
|
||||
username: ${SMTP_USERNAME:}
|
||||
password: ${SMTP_PASSWORD:}
|
||||
properties:
|
||||
mail.smtp.auth: ${SMTP_AUTH:true}
|
||||
mail.smtp.starttls.enable: ${SMTP_STARTTLS:true}
|
||||
mail.smtp.starttls.required: ${SMTP_STARTTLS:true}
|
||||
mail.smtp.connectiontimeout: 10000
|
||||
mail.smtp.timeout: 10000
|
||||
|
||||
demo:
|
||||
to: ${DEMO_TO:test@thinhha.pro}
|
||||
from: ${DEMO_FROM:${SMTP_USERNAME:}}
|
||||
count: ${DEMO_COUNT:1}
|
||||
|
||||
logging:
|
||||
level:
|
||||
io.github.sneakybugmaster.mailsender: DEBUG
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
package io.github.sneakybugmaster.mailsender.demo;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.context.DynamicPropertyRegistry;
|
||||
import org.springframework.test.context.DynamicPropertySource;
|
||||
|
||||
import com.icegreen.greenmail.util.GreenMail;
|
||||
import com.icegreen.greenmail.util.GreenMailUtil;
|
||||
import com.icegreen.greenmail.util.ServerSetupTest;
|
||||
|
||||
import jakarta.mail.internet.MimeMessage;
|
||||
|
||||
@SpringBootTest(properties = {
|
||||
"spring.mail.properties.mail.smtp.auth=false",
|
||||
"spring.mail.properties.mail.smtp.starttls.enable=false",
|
||||
"spring.mail.properties.mail.smtp.starttls.required=false",
|
||||
"spring.mail.username=",
|
||||
"demo.from=demo@mailsender.test",
|
||||
"demo.count=2",
|
||||
// keep the test offline: no real WKD lookup
|
||||
"mail.pgp.wkd.enabled=false"
|
||||
})
|
||||
class TestMailRunnerTest {
|
||||
|
||||
// Started before the Spring context, because the runner sends while the context starts.
|
||||
static GreenMail greenMail;
|
||||
|
||||
@BeforeAll
|
||||
static void startSmtp() {
|
||||
greenMail = new GreenMail(ServerSetupTest.SMTP.dynamicPort());
|
||||
greenMail.start();
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void stopSmtp() {
|
||||
greenMail.stop();
|
||||
}
|
||||
|
||||
@DynamicPropertySource
|
||||
static void smtp(DynamicPropertyRegistry registry) {
|
||||
registry.add("spring.mail.host", () -> "127.0.0.1");
|
||||
registry.add("spring.mail.port", () -> greenMail.getSmtp().getPort());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendsTestMailsToConfiguredRecipient() throws Exception {
|
||||
MimeMessage[] received = greenMail.getReceivedMessages();
|
||||
|
||||
assertThat(received).hasSize(2);
|
||||
assertThat(received[0].getAllRecipients()).extracting(Object::toString).containsExactly("test@thinhha.pro");
|
||||
assertThat(received[0].getSubject()).isEqualTo("mailsender test 1/2");
|
||||
assertThat(GreenMailUtil.getBody(received[0])).contains("no key published");
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>io.github.sneakybugmaster.mailsender</groupId>
|
||||
<artifactId>mailsender-parent</artifactId>
|
||||
<version>0.1.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>mailsender-pgp-spring-boot-starter</artifactId>
|
||||
<description>Starter: OpenPGP-encrypted mail for Spring Boot</description>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>io.github.sneakybugmaster.mailsender</groupId>
|
||||
<artifactId>mailsender-pgp-spring-boot-autoconfigure</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-mail</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
@@ -0,0 +1,121 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<groupId>io.github.sneakybugmaster.mailsender</groupId>
|
||||
<artifactId>mailsender-parent</artifactId>
|
||||
<version>0.1.0-SNAPSHOT</version>
|
||||
<packaging>pom</packaging>
|
||||
|
||||
<name>mailsender</name>
|
||||
<description>OpenPGP (WKD + PGP/MIME) encryption for Spring Boot mail</description>
|
||||
|
||||
<modules>
|
||||
<module>mailsender-pgp-core</module>
|
||||
<module>mailsender-pgp-spring-boot-autoconfigure</module>
|
||||
<module>mailsender-pgp-spring-boot-starter</module>
|
||||
<module>mailsender-demo</module>
|
||||
</modules>
|
||||
|
||||
<properties>
|
||||
<maven.compiler.release>27</maven.compiler.release>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
|
||||
<spring-boot.version>4.1.1</spring-boot.version>
|
||||
<pgpainless.version>2.0.4</pgpainless.version>
|
||||
<wkd-java.version>0.1.3</wkd-java.version>
|
||||
<caffeine.version>3.3.0</caffeine.version>
|
||||
<greenmail.version>2.1.14</greenmail.version>
|
||||
</properties>
|
||||
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-dependencies</artifactId>
|
||||
<version>${spring-boot.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.github.sneakybugmaster.mailsender</groupId>
|
||||
<artifactId>mailsender-pgp-core</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.github.sneakybugmaster.mailsender</groupId>
|
||||
<artifactId>mailsender-pgp-spring-boot-autoconfigure</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.github.sneakybugmaster.mailsender</groupId>
|
||||
<artifactId>mailsender-pgp-spring-boot-starter</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.pgpainless</groupId>
|
||||
<artifactId>pgpainless-core</artifactId>
|
||||
<version>${pgpainless.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.pgpainless</groupId>
|
||||
<artifactId>wkd-java</artifactId>
|
||||
<version>${wkd-java.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.github.ben-manes.caffeine</groupId>
|
||||
<artifactId>caffeine</artifactId>
|
||||
<version>${caffeine.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.icegreen</groupId>
|
||||
<artifactId>greenmail-junit5</artifactId>
|
||||
<version>${greenmail.version}</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
<build>
|
||||
<pluginManagement>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>3.14.0</version>
|
||||
<configuration>
|
||||
<parameters>true</parameters>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>3.5.3</version>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</pluginManagement>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-enforcer-plugin</artifactId>
|
||||
<version>3.5.0</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>enforce-java</id>
|
||||
<goals>
|
||||
<goal>enforce</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<rules>
|
||||
<requireJavaVersion>
|
||||
<version>[27,)</version>
|
||||
</requireJavaVersion>
|
||||
</rules>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
Reference in new issue
Block a user