mirror of
https://github.com/sneakybugmaster/mailsender.git
synced 2026-11-10 19:48:49 +00:00
- 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
176 lines
9.3 KiB
Markdown
176 lines
9.3 KiB
Markdown
# 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.
|