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