Files
mailsender/docs/PLAN.md
T
Claude 7c2fb0142e Add OpenPGP and S/MIME signing of outgoing mail
mail.pgp.signing.type selects NONE (default), OPENPGP or SMIME, with one
key/certificate per From address.

- OPENPGP: plaintext copies become PGP/MIME multipart/signed (RFC 3156 s5,
  detached signature over the exact content entity bytes); encrypted copies
  are signed inside the encryption (combined method)
- SMIME: plaintext copies become S/MIME multipart/signed (RFC 8551, via
  Bouncy Castle bcjmail); encrypted copies are S/MIME-signed, then
  PGP/MIME-encrypted
- signed bodies are attached as raw bytes so they reach the wire unchanged;
  shared MIME helpers move to MimeEntities
- keys are validated at startup (signing capability, address match,
  validity, passphrase / key-certificate match)
- mail.pgp.signing.required refuses mail from addresses without a key
  (MailSigningException); RELAY mode signs before handing over
- SendReport.signature(), metric tag signature, refused{cause}
- core test-jar shares key/certificate and verification helpers
- end-to-end tests verify signatures on the bytes received over SMTP

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G71TQ5jDETVwdUJxGAwaiT
2026-10-15 09:49:45 +00:00

12 KiB
Raw Blame History

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 25 LTS (maven.compiler.release=25)
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)

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
  • 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.

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 (M8)

Micrometer is optional; with a MeterRegistry bean these are registered:

  • mail.pgp.recipients{outcome=encrypted|plaintext|relayed, policy, reason=none|not_found|unreachable|rejected|plaintext_policy} — a downgrade is outcome=plaintext, policy=opportunistic
  • mail.pgp.refused — messages refused by the policy
  • mail.pgp.key.lookup{result=found|not_found|unreachable, source=wkd_advanced|wkd_direct|none} — timer for lookups not answered from the cache
  • mail.pgp.key.cache.size

Actuator is optional; when the pgpkeys endpoint is exposed (management.endpoints.web.exposure.include=pgpkeys):

  • GET /actuator/pgpkeys — cache size
  • GET /actuator/pgpkeys/{email} — policy, lookup result, and validation of each key
  • DELETE /actuator/pgpkeys/{email}, DELETE /actuator/pgpkeys — evict cached lookups (key rotation)

Extension points: KeyDiscoveryCustomizer (wraps WKD discovery below the cache) and SecureMailSenderCustomizer.

Signing (M9)

mail.pgp.signing.type: NONE (default), OPENPGP or SMIME; one key per From address.

  • OPENPGP — plaintext copies: multipart/signed; protocol="application/pgp-signature"; micalg=pgp-<hash>, detached signature over the exact bytes of the content entity (RFC 3156 section 5). Encrypted copies: signature inside the encryption (RFC 3156 section 6.2, combined method).
  • SMIME — plaintext copies: multipart/signed; protocol="application/pkcs7-signature" with the certificate chain (Bouncy Castle bcjmail, RFC 8551). Encrypted copies: the S/MIME-signed entity is PGP/MIME-encrypted.
  • Signed bodies are attached as raw bytes and the signed part is a parsed MIME part, so JavaMail writes them back byte for byte; tests verify signatures on the bytes received over SMTP, and the output was checked with gpg --verify and openssl smime -verify.
  • Keys are validated at startup (can sign, names the address, currently valid, passphrase/private key works).
  • required=true refuses mail from an address without a key (MailSigningException, metric mail.pgp.refused{cause=signing_required}); otherwise it is sent unsigned with a warning.
  • RELAY mode signs before handing the message to the gateway.

Milestones

  1. Skeleton — parent POM, modules, CI (GitHub Actions, mvn verify on Temurin 25). ✅
  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 (RFC 9788, opt-in), actuator endpoint, docs. ✅ Encrypting no longer regenerates the caller's Message-ID.
  9. Signing — OpenPGP and S/MIME signing, configurable per application. ✅