Scaffold PGP mail modules with WKD key discovery and demo app

- Maven multi-module build on JDK 27 and Spring Boot 4.1.1 (BOM import)
- core: WKD discovery (advanced -> direct fallback), HTTP fetcher with
  timeouts, no redirects and a size limit, Caffeine cache with per-outcome
  TTLs capped at key expiry, pinned keys, composite chain
- autoconfigure: mail.pgp.* properties and discovery beans
- demo: sends test mails (plaintext for now) and reports the WKD result
- docs/PLAN.md: design, decisions and milestones

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G71TQ5jDETVwdUJxGAwaiT
This commit is contained in:
Claude committed 2026-10-12 03:13:23 +00:00
1 parent 7701ffe034
commit 213df4c34e
35 files changed
+1885

No files matched your search

+175
View File
@@ -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.