Files
mailsender/docs/PLAN.md
T
Claude 3bce057eab Add encryption policy, split delivery and SecureMailSender
- delivery: PolicyResolver (address > *@domain > default), RecipientKeyResolver
  (discovery + validation), DeliveryPlanner (REQUIRE refusal, SPLIT/FAIL,
  own copy per Bcc with hidden key IDs), MessageAssembler with sender keys
  for readable sent copies, EnvelopeMimeMessage for per-copy SMTP envelopes
- sender: SecureMailSender with BUILT_IN and RELAY implementations and a
  per-recipient SendReport; relay passes the strictest policy as a header hint
- autoconfigure: mode, default-policy, mixed-mode, recipients, sender-keys
  and relay properties; relay sender is not a bean so spring.mail stays intact
- demo: sends through SecureMailSender

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G71TQ5jDETVwdUJxGAwaiT
2026-10-12 03:27:40 +00:00

183 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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. With `policy-header` set, the strictest
policy among the recipients (`require` > `opportunistic` > `plaintext`) is passed as a header hint; enforcement is
the gateway's responsibility. 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, revoked UID, revoked encryption subkey, wrong UID, valid. ✅
PGPainless and Bouncy Castle can disagree on a shortened expiry; the validator uses the earlier date.
4. **Encryptor + PGP/MIME** — round-trip decrypt test, structure test, CRLF canonicalization, attachments. ✅
Verified live: the demo's mail to `test@thinhha.pro` is addressed to its cv25519 subkey `7CEE00AF05A08313`.
5. **Policy + delivery planner** — `PolicyResolver`, `RecipientKeyResolver`, `DeliveryPlanner`, `MessageAssembler`,
`EnvelopeMimeMessage`; tests over key/no-key × REQUIRE/OPPORTUNISTIC/PLAINTEXT × To/Cc/Bcc. ✅
All copies of one message share its Date and Message-ID; duplicates get one copy; nobody encryptable → one
plaintext copy (no split).
6. **SecureMailSender (BUILT_IN + RELAY)**, `SendReport`, sender keys, autoconfig switch. ✅
GreenMail integration test checks per-mailbox (envelope) delivery, decryption by recipient and sender, REQUIRE
refusal with nothing sent, and relay mode with the policy header. Metrics moved to M8.
7. **Demo + E2E** with GreenMail; manual check against a real WKD domain, decrypt in Thunderbird.
Demo app ✅ — encrypts when a valid key is found, otherwise sends plaintext (logged). Verified live:
`test@thinhha.pro` → found via advanced method, fpr `ADA6EFA0CC1630D602889820112495670FF1B4F0`.
8. **Hardening** — Micrometer metrics, protected headers, actuator endpoint, docs.