- 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
9.3 KiB
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_FAILUREis 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 treatsUnreachablelikeNotFoundfor 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 viaCompositeKeyDiscovery.
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()withEncryptionOptions.encryptCommunications(), ASCII armor on. - Inner entity = the original body (text/html/attachments) with its own
Content-Typeheaders, serialized withwriteTo(), CRLF-canonicalized, then encrypted. - Outer entity:
multipart/encrypted; protocol="application/pgp-encrypted"with part 1application/pgp-encrypted=Version: 1, part 2application/octet-stream; name="encrypted.asc". EncryptedMultipartsubclassesMimeMultipartto set the content type itself, otherwisesaveChanges()drops theprotocolparameter.- Protected headers (RFC 9788) opt-in:
mail.pgp.protected-headers=truemoves 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)
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.
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
RelaySecureMailSenderuses its ownJavaMailSenderpointed at the relay (the app's normalspring.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.
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
pgpkeysto inspect/evict a cached address.
Milestones
- Skeleton — parent POM, modules, CI. ✅
- 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. ✅ - Validation — generated test keys: expired, revoked, sign-only, wrong UID, valid.
- Encryptor + PGP/MIME — round-trip decrypt test, golden structure test, CRLF canonicalization.
- Policy + delivery planner — table-driven tests over key/no-key × REQUIRE/OPPORTUNISTIC/PLAINTEXT × To/Cc/Bcc;
EnvelopeMimeMessage. - SecureMailSender (BUILT_IN + RELAY),
SendReport, sender keys, metrics, autoconfig switch. - 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, fprADA6EFA0CC1630D602889820112495670FF1B4F0. - Hardening — protected headers, actuator endpoint, docs.