Merge pull request #1 from sneakybugmaster/claude/bold-brahmagupta-nsa7hh

Add OpenPGP encryption for Spring Boot mail with WKD discovery
This commit is contained in:
thinh authored and GitHub committed 2026-10-12 10:43:59 +07:00
commit 34191f91dd
79 files changed
+4925

No files matched your search

+31
View File
@@ -0,0 +1,31 @@
name: CI
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '27'
cache: maven
- name: Build and test
run: mvn -B -ntp verify
- name: Upload test reports
if: failure()
uses: actions/upload-artifact@v4
with:
name: surefire-reports
path: '**/target/surefire-reports/'
+5
View File
@@ -0,0 +1,5 @@
target/
.idea/
*.iml
.vscode/
.DS_Store
+76
View File
@@ -0,0 +1,76 @@
# mailsender
OpenPGP encryption for Spring Boot mail: discovers recipients' keys via Web Key Directory (WKD) and sends
PGP/MIME (RFC 3156), or hands mail to an encrypting relay. See [docs/PLAN.md](docs/PLAN.md) for the design
and milestone status.
Requires JDK 27 and Maven 3.9+. Built on Spring Boot 4.1.1 and PGPainless 2.0.
```
mvn verify
```
## Modules
| Module | Contents |
|---|---|
| `mailsender-pgp-core` | WKD key discovery, caching, pinned keys, certificate validation, OpenPGP encryption, PGP/MIME (no Spring) |
| `mailsender-pgp-spring-boot-autoconfigure` | `mail.pgp.*` properties and beans |
| `mailsender-pgp-spring-boot-starter` | dependency bundle for applications |
| `mailsender-demo` | sample app that sends test mails |
## Usage
Add `pro.thinhha.mailsender:mailsender-pgp-spring-boot-starter`, configure `spring.mail.*` as usual, and send
through `SecureMailSender` instead of `JavaMailSender`:
```java
SendReport report = secureMailSender.send(message); // MimeMessage or SimpleMailMessage
```
Each recipient's key is looked up via WKD (cached) and validated. The message is then sent PGP/MIME-encrypted to
recipients with a usable key and in plaintext to the rest (logged as a downgrade), or refused with
`EncryptionPolicyException` if a `REQUIRE` recipient has no key. `SendReport` lists the outcome per recipient.
```yaml
mail:
pgp:
mode: BUILT_IN # or RELAY: hand plaintext to an encrypting gateway (mail.pgp.relay.*)
default-policy: OPPORTUNISTIC # REQUIRE | OPPORTUNISTIC | PLAINTEXT
mixed-mode: SPLIT # or FAIL
recipients:
"[*@partner.example]": REQUIRE
sender-keys: # your own PUBLIC key, so sent copies stay readable
"[noreply@example.com]": file:/etc/mail/noreply.pub.asc
```
Map keys that are addresses need the `[...]` brackets. See [docs/PLAN.md](docs/PLAN.md) for all options.
- `mail.pgp.protected-headers=true` encrypts the real Subject too (RFC 9788); the visible Subject becomes `[...]`.
- With Micrometer, `mail.pgp.recipients{outcome,policy,reason}`, `mail.pgp.refused`, `mail.pgp.key.lookup` and
`mail.pgp.key.cache.size` are recorded.
- With Actuator, expose `pgpkeys` to inspect an address (`GET /actuator/pgpkeys/{email}`) or evict cached keys
(`DELETE /actuator/pgpkeys/{email}`).
## Demo app
The demo app sends `DEMO_COUNT` test mails (default: 1 to `test@thinhha.pro`) through `SecureMailSender` and logs
the outcome per recipient: PGP/MIME-encrypted (RFC 3156) when a valid WKD key is found, otherwise plaintext.
Set `MAIL_PGP_MODE=RELAY` and `RELAY_HOST`/`RELAY_PORT`/`RELAY_USERNAME`/`RELAY_PASSWORD` to send via a gateway.
```
mvn -q install -DskipTests
SMTP_HOST=smtp.example.com SMTP_PORT=587 \
SMTP_USERNAME=me@example.com SMTP_PASSWORD='app-password' \
DEMO_TO=test@thinhha.pro DEMO_COUNT=1 \
java -jar mailsender-demo/target/mailsender-demo-0.1.0-SNAPSHOT.jar
```
| Variable | Default | Meaning |
|---|---|---|
| `SMTP_HOST` / `SMTP_PORT` | `localhost` / `587` | SMTP server |
| `SMTP_USERNAME` / `SMTP_PASSWORD` | empty | SMTP credentials |
| `SMTP_AUTH` / `SMTP_STARTTLS` | `true` / `true` | set to `false` for a local test server |
| `DEMO_TO` | `test@thinhha.pro` | recipient |
| `DEMO_FROM` | `SMTP_USERNAME` | sender address |
| `DEMO_COUNT` | `1` | number of mails |
+195
View File
@@ -0,0 +1,195 @@
# 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 (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`.
## Milestones
1. **Skeleton** — parent POM, modules, CI (GitHub Actions, `mvn verify` on Temurin 27). ✅
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.
+54
View File
@@ -0,0 +1,54 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>pro.thinhha.mailsender</groupId>
<artifactId>mailsender-parent</artifactId>
<version>0.1.0-SNAPSHOT</version>
</parent>
<artifactId>mailsender-demo</artifactId>
<description>Demo app: looks up the recipient's WKD key and sends test e-mails</description>
<dependencies>
<dependency>
<groupId>pro.thinhha.mailsender</groupId>
<artifactId>mailsender-pgp-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.icegreen</groupId>
<artifactId>greenmail-junit5</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>${spring-boot.version}</version>
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,14 @@
package pro.thinhha.mailsender.demo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
@SpringBootApplication
@ConfigurationPropertiesScan
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}
@@ -0,0 +1,16 @@
package pro.thinhha.mailsender.demo;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;
/**
* @param to recipient of the test mails
* @param from sender address; most SMTP providers require it to match the authenticated account
* @param count number of test mails to send
*/
@ConfigurationProperties("demo")
public record DemoProperties(
@DefaultValue("test@thinhha.pro") String to,
String from,
@DefaultValue("1") int count) {
}
@@ -0,0 +1,58 @@
package pro.thinhha.mailsender.demo;
import java.time.OffsetDateTime;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Component;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import pro.thinhha.mailsender.pgp.sender.SecureMailSender;
import pro.thinhha.mailsender.pgp.sender.SendReport;
/**
* Sends {@code demo.count} test mails through {@link SecureMailSender}, which encrypts per recipient according to
* {@code mail.pgp.*} (or hands them to the relay in RELAY mode), and logs what happened to each recipient.
*/
@Component
class TestMailRunner implements ApplicationRunner {
private static final Logger log = LoggerFactory.getLogger(TestMailRunner.class);
private final DemoProperties properties;
private final SecureMailSender mailSender;
TestMailRunner(DemoProperties properties, SecureMailSender mailSender) {
this.properties = properties;
this.mailSender = mailSender;
}
@Override
public void run(ApplicationArguments args) throws MessagingException {
for (int i = 1; i <= properties.count(); i++) {
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, "UTF-8");
if (properties.from() != null && !properties.from().isBlank()) {
helper.setFrom(properties.from());
}
helper.setTo(properties.to());
helper.setSubject("mailsender test %d/%d".formatted(i, properties.count()));
helper.setText("""
Test mail %d of %d from the mailsender demo, sent %s.
If you can read this in a PGP-capable client that shows it as encrypted,
WKD discovery and PGP/MIME encryption worked.
""".formatted(i, properties.count(), OffsetDateTime.now()));
SendReport report = mailSender.send(message);
for (SendReport.RecipientOutcome outcome : report.recipients()) {
log.info("Test mail {}/{} to {}: {}{}", i, properties.count(), outcome.address(), outcome.outcome(),
outcome.detail() == null ? "" : " (" + outcome.detail() + ")");
}
}
}
}
@@ -0,0 +1,41 @@
spring:
main:
web-application-type: none
mail:
host: ${SMTP_HOST:localhost}
port: ${SMTP_PORT:587}
username: ${SMTP_USERNAME:}
password: ${SMTP_PASSWORD:}
properties:
mail.smtp.auth: ${SMTP_AUTH:true}
mail.smtp.starttls.enable: ${SMTP_STARTTLS:true}
mail.smtp.starttls.required: ${SMTP_STARTTLS:true}
mail.smtp.connectiontimeout: 10000
mail.smtp.timeout: 10000
mail:
pgp:
mode: ${MAIL_PGP_MODE:BUILT_IN} # BUILT_IN | RELAY
default-policy: ${MAIL_PGP_DEFAULT_POLICY:OPPORTUNISTIC}
mixed-mode: SPLIT
protected-headers: ${MAIL_PGP_PROTECTED_HEADERS:false}
# recipients:
# "[*@partner.example]": REQUIRE
# Readable sent copies: the sender's own PUBLIC key per From address.
# sender-keys:
# "[noreply@example.com]": file:/etc/mail/noreply.pub.asc
relay:
host: ${RELAY_HOST:}
port: ${RELAY_PORT:587}
username: ${RELAY_USERNAME:}
password: ${RELAY_PASSWORD:}
policy-header: X-Encryption-Policy
demo:
to: ${DEMO_TO:test@thinhha.pro}
from: ${DEMO_FROM:${SMTP_USERNAME:}}
count: ${DEMO_COUNT:1}
logging:
level:
pro.thinhha.mailsender: DEBUG
@@ -0,0 +1,85 @@
package pro.thinhha.mailsender.demo;
import static org.assertj.core.api.Assertions.assertThat;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import org.bouncycastle.openpgp.api.OpenPGPKey;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.pgpainless.PGPainless;
import org.pgpainless.decryption_verification.ConsumerOptions;
import org.pgpainless.decryption_verification.DecryptionStream;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import com.icegreen.greenmail.util.GreenMail;
import com.icegreen.greenmail.util.ServerSetupTest;
import jakarta.mail.Multipart;
import jakarta.mail.internet.ContentType;
import jakarta.mail.internet.MimeBodyPart;
import jakarta.mail.internet.MimeMessage;
/** The recipient has a (pinned) key, so the demo must deliver a decryptable PGP/MIME message. */
@SpringBootTest(properties = {
"spring.mail.properties.mail.smtp.auth=false",
"spring.mail.properties.mail.smtp.starttls.enable=false",
"spring.mail.properties.mail.smtp.starttls.required=false",
"spring.mail.username=",
"demo.from=demo@mailsender.test",
"mail.pgp.wkd.enabled=false"
})
class EncryptedTestMailTest {
static final PGPainless API = PGPainless.getInstance();
static final OpenPGPKey RECIPIENT = API.generateKey().modernKeyRing("Test Account <test@thinhha.pro>");
static GreenMail greenMail;
static Path keyFile;
@BeforeAll
static void start() throws Exception {
keyFile = Files.createTempFile("recipient", ".asc");
Files.writeString(keyFile, RECIPIENT.toCertificate().toAsciiArmoredString());
greenMail = new GreenMail(ServerSetupTest.SMTP.dynamicPort());
greenMail.start();
}
@AfterAll
static void stop() throws Exception {
greenMail.stop();
Files.deleteIfExists(keyFile);
}
@DynamicPropertySource
static void properties(DynamicPropertyRegistry registry) {
registry.add("spring.mail.host", () -> "127.0.0.1");
registry.add("spring.mail.port", () -> greenMail.getSmtp().getPort());
registry.add("mail.pgp.static-keys[test@thinhha.pro]", () -> "file:" + keyFile);
}
@Test
void deliversDecryptablePgpMime() throws Exception {
MimeMessage[] received = greenMail.getReceivedMessages();
assertThat(received).hasSize(1);
MimeMessage message = received[0];
assertThat(new ContentType(message.getContentType()).getBaseType()).isEqualTo("multipart/encrypted");
assertThat(message.getSubject()).isEqualTo("mailsender test 1/1");
Multipart parts = (Multipart) message.getContent();
byte[] armored = parts.getBodyPart(1).getInputStream().readAllBytes();
ByteArrayOutputStream inner = new ByteArrayOutputStream();
try (DecryptionStream in = API.processMessage().onInputStream(new ByteArrayInputStream(armored))
.withOptions(ConsumerOptions.get(API).addDecryptionKey(RECIPIENT))) {
in.transferTo(inner);
}
MimeBodyPart entity = new MimeBodyPart(new ByteArrayInputStream(inner.toByteArray()));
assertThat(entity.getContent().toString()).contains("Test mail 1 of 1");
}
}
@@ -0,0 +1,59 @@
package pro.thinhha.mailsender.demo;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import com.icegreen.greenmail.util.GreenMail;
import com.icegreen.greenmail.util.GreenMailUtil;
import com.icegreen.greenmail.util.ServerSetupTest;
import jakarta.mail.internet.MimeMessage;
@SpringBootTest(properties = {
"spring.mail.properties.mail.smtp.auth=false",
"spring.mail.properties.mail.smtp.starttls.enable=false",
"spring.mail.properties.mail.smtp.starttls.required=false",
"spring.mail.username=",
"demo.from=demo@mailsender.test",
"demo.count=2",
// keep the test offline: no real WKD lookup
"mail.pgp.wkd.enabled=false"
})
class PlaintextTestMailTest {
// Started before the Spring context, because the runner sends while the context starts.
static GreenMail greenMail;
@BeforeAll
static void startSmtp() {
greenMail = new GreenMail(ServerSetupTest.SMTP.dynamicPort());
greenMail.start();
}
@AfterAll
static void stopSmtp() {
greenMail.stop();
}
@DynamicPropertySource
static void smtp(DynamicPropertyRegistry registry) {
registry.add("spring.mail.host", () -> "127.0.0.1");
registry.add("spring.mail.port", () -> greenMail.getSmtp().getPort());
}
@Test
void sendsTestMailsToConfiguredRecipient() throws Exception {
MimeMessage[] received = greenMail.getReceivedMessages();
assertThat(received).hasSize(2);
assertThat(received[0].getAllRecipients()).extracting(Object::toString).containsExactly("test@thinhha.pro");
assertThat(received[0].getSubject()).isEqualTo("mailsender test 1/2");
assertThat(GreenMailUtil.getBody(received[0])).contains("Test mail 1 of 2");
}
}
+55
View File
@@ -0,0 +1,55 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>pro.thinhha.mailsender</groupId>
<artifactId>mailsender-parent</artifactId>
<version>0.1.0-SNAPSHOT</version>
</parent>
<artifactId>mailsender-pgp-core</artifactId>
<description>WKD key discovery, validation, OpenPGP encryption and PGP/MIME building (no Spring)</description>
<dependencies>
<dependency>
<groupId>org.pgpainless</groupId>
<artifactId>pgpainless-core</artifactId>
</dependency>
<dependency>
<groupId>org.pgpainless</groupId>
<artifactId>wkd-java</artifactId>
</dependency>
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
</dependency>
<dependency>
<!-- Jakarta Mail API + implementation (same as spring-boot-starter-mail) -->
<groupId>org.eclipse.angus</groupId>
<artifactId>angus-mail</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-simple</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,56 @@
package pro.thinhha.mailsender.pgp.crypto;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.Collection;
import java.util.Objects;
import org.bouncycastle.openpgp.PGPException;
import org.bouncycastle.openpgp.api.OpenPGPCertificate;
import org.pgpainless.PGPainless;
import org.pgpainless.encryption_signing.EncryptionOptions;
import org.pgpainless.encryption_signing.EncryptionStream;
import org.pgpainless.encryption_signing.ProducerOptions;
/** Encrypts data to OpenPGP certificates and returns the ASCII-armored message. */
public final class PgpEncryptor {
private final PGPainless api;
public PgpEncryptor() {
this(PGPainless.getInstance());
}
public PgpEncryptor(PGPainless api) {
this.api = Objects.requireNonNull(api, "api");
}
/**
* @param recipients certificates whose key IDs appear in the message
* @param hiddenRecipients certificates encrypted to with a wildcard key ID, so other recipients cannot tell
* they received it (use for Bcc)
* @return the ASCII-armored message, without armor headers
* @throws IllegalArgumentException if there is no recipient at all
*/
public byte[] encrypt(byte[] plaintext, Collection<OpenPGPCertificate> recipients,
Collection<OpenPGPCertificate> hiddenRecipients) throws IOException {
if (recipients.isEmpty() && hiddenRecipients.isEmpty()) {
throw new IllegalArgumentException("At least one recipient is required");
}
EncryptionOptions options = EncryptionOptions.encryptCommunications(api);
recipients.forEach(options::addRecipient);
hiddenRecipients.forEach(options::addHiddenRecipient);
ByteArrayOutputStream out = new ByteArrayOutputStream(plaintext.length + 1024);
try (EncryptionStream stream = api.generateMessage()
.onOutputStream(out)
.withOptions(ProducerOptions.encrypt(options)
.setAsciiArmor(true)
.setHideArmorHeaders(true))) {
stream.write(plaintext);
} catch (PGPException e) {
throw new IOException("OpenPGP encryption failed", e);
}
return out.toByteArray();
}
}
@@ -0,0 +1,34 @@
package pro.thinhha.mailsender.pgp.delivery;
import java.util.List;
import org.bouncycastle.openpgp.api.OpenPGPCertificate;
import jakarta.mail.internet.InternetAddress;
/**
* One copy of the message to send.
*
* @param envelope SMTP recipients of this copy (headers still show the original To/Cc)
* @param recipientKeys certificates to encrypt to; empty for a plaintext copy
* @param hiddenRecipients true to encrypt without revealing key IDs (Bcc copies)
*/
public record Delivery(List<InternetAddress> envelope, List<OpenPGPCertificate> recipientKeys,
boolean hiddenRecipients) {
public Delivery {
envelope = List.copyOf(envelope);
recipientKeys = List.copyOf(recipientKeys);
if (envelope.isEmpty()) {
throw new IllegalArgumentException("A delivery needs at least one envelope recipient");
}
}
static Delivery plaintext(List<InternetAddress> envelope) {
return new Delivery(envelope, List.of(), false);
}
public boolean encrypted() {
return !recipientKeys.isEmpty();
}
}
@@ -0,0 +1,12 @@
package pro.thinhha.mailsender.pgp.delivery;
import java.util.List;
/** The copies to send and the per-recipient decisions behind them. */
public record DeliveryPlan(List<Delivery> deliveries, List<RecipientDecision> decisions) {
public DeliveryPlan {
deliveries = List.copyOf(deliveries);
decisions = List.copyOf(decisions);
}
}
@@ -0,0 +1,140 @@
package pro.thinhha.mailsender.pgp.delivery;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import org.bouncycastle.openpgp.api.OpenPGPCertificate;
import jakarta.mail.internet.InternetAddress;
/**
* Turns the recipients of one message into deliveries:
* <ul>
* <li>A REQUIRE recipient without a usable key → {@link EncryptionPolicyException}, nothing is sent.</li>
* <li>Nobody gets encryption → one plaintext copy to everyone.</li>
* <li>Otherwise one encrypted copy for To/Cc recipients with keys, one plaintext copy for the other To/Cc
* recipients, and an own copy per Bcc recipient (encrypted with hidden key IDs when possible).
* With {@link MixedMode#FAIL}, a mix of encrypted and plaintext recipients is refused instead.</li>
* </ul>
* An address listed more than once receives one copy, under its first header.
*/
public final class DeliveryPlanner {
private final PolicyResolver policies;
private final RecipientKeyResolver keys;
private final MixedMode mixedMode;
public DeliveryPlanner(PolicyResolver policies, RecipientKeyResolver keys, MixedMode mixedMode) {
this.policies = Objects.requireNonNull(policies, "policies");
this.keys = Objects.requireNonNull(keys, "keys");
this.mixedMode = Objects.requireNonNull(mixedMode, "mixedMode");
}
public DeliveryPlan plan(List<InternetAddress> to, List<InternetAddress> cc, List<InternetAddress> bcc) {
Map<String, Recipient> recipients = new LinkedHashMap<>();
add(recipients, to, RecipientType.TO);
add(recipients, cc, RecipientType.CC);
add(recipients, bcc, RecipientType.BCC);
if (recipients.isEmpty()) {
throw new IllegalArgumentException("Message has no recipients");
}
List<String> missingRequired = new ArrayList<>();
for (Recipient recipient : recipients.values()) {
recipient.policy = policies.resolve(recipient.email());
recipient.key = recipient.policy == EncryptionPolicy.PLAINTEXT
? new RecipientKey.NotLookedUp()
: keys.resolve(recipient.email());
if (recipient.policy == EncryptionPolicy.REQUIRE && !recipient.hasKey()) {
missingRequired.add(recipient.email());
}
}
if (!missingRequired.isEmpty()) {
throw new EncryptionPolicyException("Encryption required but no usable key", missingRequired);
}
List<Recipient> all = List.copyOf(recipients.values());
boolean anyEncrypted = all.stream().anyMatch(Recipient::hasKey);
List<String> plaintext = all.stream().filter(r -> !r.hasKey()).map(Recipient::email).toList();
if (!anyEncrypted) {
return new DeliveryPlan(
List.of(Delivery.plaintext(all.stream().map(Recipient::address).toList())),
decisions(all));
}
if (mixedMode == MixedMode.FAIL && !plaintext.isEmpty()) {
throw new EncryptionPolicyException("Mixed encrypted/plaintext recipients not allowed", plaintext);
}
return new DeliveryPlan(split(all), decisions(all));
}
private static List<Delivery> split(List<Recipient> all) {
List<Delivery> deliveries = new ArrayList<>();
List<Recipient> visible = all.stream().filter(r -> r.type != RecipientType.BCC).toList();
List<Recipient> visibleEncrypted = visible.stream().filter(Recipient::hasKey).toList();
if (!visibleEncrypted.isEmpty()) {
deliveries.add(new Delivery(addresses(visibleEncrypted), certificates(visibleEncrypted), false));
}
List<Recipient> visiblePlain = visible.stream().filter(r -> !r.hasKey()).toList();
if (!visiblePlain.isEmpty()) {
deliveries.add(Delivery.plaintext(addresses(visiblePlain)));
}
for (Recipient bcc : all) {
if (bcc.type == RecipientType.BCC) {
deliveries.add(bcc.hasKey()
? new Delivery(List.of(bcc.address), certificates(List.of(bcc)), true)
: Delivery.plaintext(List.of(bcc.address)));
}
}
return deliveries;
}
private static List<RecipientDecision> decisions(List<Recipient> all) {
return all.stream()
.map(r -> new RecipientDecision(r.email(), r.type, r.policy, r.key, r.hasKey()))
.toList();
}
private static List<InternetAddress> addresses(List<Recipient> recipients) {
return recipients.stream().map(Recipient::address).toList();
}
private static List<OpenPGPCertificate> certificates(List<Recipient> recipients) {
return recipients.stream().map(r -> ((RecipientKey.Usable) r.key).certificate()).toList();
}
private static void add(Map<String, Recipient> recipients, List<InternetAddress> addresses, RecipientType type) {
for (InternetAddress address : addresses) {
recipients.putIfAbsent(address.getAddress().toLowerCase(Locale.ROOT), new Recipient(address, type));
}
}
private static final class Recipient {
final InternetAddress address;
final RecipientType type;
EncryptionPolicy policy;
RecipientKey key;
Recipient(InternetAddress address, RecipientType type) {
this.address = address;
this.type = type;
}
InternetAddress address() {
return address;
}
String email() {
return address.getAddress();
}
/** Will this recipient get an encrypted copy? PLAINTEXT recipients never do. */
boolean hasKey() {
return key instanceof RecipientKey.Usable;
}
}
}
@@ -0,0 +1,11 @@
package pro.thinhha.mailsender.pgp.delivery;
/** What to do for a recipient, depending on whether a usable key exists. */
public enum EncryptionPolicy {
/** Encrypt; refuse to send if no usable key exists. */
REQUIRE,
/** Encrypt when a usable key exists, otherwise send in plaintext (logged as a downgrade). */
OPPORTUNISTIC,
/** Always send in plaintext; no key lookup. */
PLAINTEXT
}
@@ -0,0 +1,19 @@
package pro.thinhha.mailsender.pgp.delivery;
import java.util.List;
/** The message cannot be sent without violating the encryption policy. Nothing was sent. */
public class EncryptionPolicyException extends RuntimeException {
private final List<String> addresses;
public EncryptionPolicyException(String message, List<String> addresses) {
super(message + ": " + String.join(", ", addresses));
this.addresses = List.copyOf(addresses);
}
/** The recipients that caused the refusal. */
public List<String> addresses() {
return addresses;
}
}
@@ -0,0 +1,35 @@
package pro.thinhha.mailsender.pgp.delivery;
import java.util.List;
import jakarta.mail.Address;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
/**
* A copy of a message whose SMTP envelope is limited to some recipients while the To/Cc headers stay intact.
* {@code JavaMailSenderImpl} (like {@code Transport.send}) uses {@link #getAllRecipients()} as the envelope.
*/
public final class EnvelopeMimeMessage extends MimeMessage {
private final Address[] envelope;
public EnvelopeMimeMessage(MimeMessage source, List<? extends Address> envelope) throws MessagingException {
super(source);
this.envelope = envelope.toArray(Address[]::new);
removeHeader("Bcc");
}
@Override
public Address[] getAllRecipients() {
return envelope.clone();
}
/** Keeps the source's Message-ID; the default would generate a new one on every save. */
@Override
protected void updateMessageID() throws MessagingException {
if (getHeader("Message-ID") == null) {
super.updateMessageID();
}
}
}
@@ -0,0 +1,66 @@
package pro.thinhha.mailsender.pgp.delivery;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Objects;
import java.util.function.Supplier;
import org.bouncycastle.openpgp.api.OpenPGPCertificate;
import jakarta.mail.Address;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.InternetAddress;
import jakarta.mail.internet.MimeMessage;
import pro.thinhha.mailsender.pgp.mime.PgpMimeEncryptor;
/** Builds the concrete messages for a {@link DeliveryPlan}. */
public final class MessageAssembler {
private final PgpMimeEncryptor mimeEncryptor;
private final SenderKeyProvider senderKeys;
public MessageAssembler(PgpMimeEncryptor mimeEncryptor, SenderKeyProvider senderKeys) {
this.mimeEncryptor = Objects.requireNonNull(mimeEncryptor, "mimeEncryptor");
this.senderKeys = Objects.requireNonNull(senderKeys, "senderKeys");
}
/**
* @param newMessage creates empty messages bound to the sending session, e.g.
* {@code JavaMailSender::createMimeMessage}
*/
public List<MimeMessage> assemble(MimeMessage original, DeliveryPlan plan, Supplier<MimeMessage> newMessage)
throws MessagingException, IOException {
List<OpenPGPCertificate> selfKeys = senderKey(original);
// All copies are the same message to their readers: same Date and Message-ID.
if (original.getSentDate() == null) {
original.setSentDate(new Date());
}
original.saveChanges();
String messageId = original.getMessageID();
List<MimeMessage> messages = new ArrayList<>();
for (Delivery delivery : plan.deliveries()) {
MimeMessage message = original;
if (delivery.encrypted()) {
List<OpenPGPCertificate> visible = new ArrayList<>(selfKeys);
List<OpenPGPCertificate> hidden = new ArrayList<>();
(delivery.hiddenRecipients() ? hidden : visible).addAll(delivery.recipientKeys());
message = mimeEncryptor.encrypt(original, visible, hidden, newMessage);
}
EnvelopeMimeMessage copy = new EnvelopeMimeMessage(message, delivery.envelope());
copy.setHeader("Message-ID", messageId);
messages.add(copy);
}
return messages;
}
private List<OpenPGPCertificate> senderKey(MimeMessage original) throws MessagingException {
Address[] from = original.getFrom();
if (from == null || from.length == 0 || !(from[0] instanceof InternetAddress sender)) {
return List.of();
}
return senderKeys.keyFor(sender.getAddress()).map(List::of).orElse(List.of());
}
}
@@ -0,0 +1,9 @@
package pro.thinhha.mailsender.pgp.delivery;
/** What to do when some recipients of one message will get it encrypted and others in plaintext. */
public enum MixedMode {
/** Send an encrypted copy to recipients with keys and a plaintext copy to the rest. */
SPLIT,
/** Refuse to send. */
FAIL
}
@@ -0,0 +1,44 @@
package pro.thinhha.mailsender.pgp.delivery;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
/**
* Resolves the {@link EncryptionPolicy} for an address: an exact address rule wins over a {@code *@domain} rule,
* which wins over the default. Matching ignores case.
*/
public final class PolicyResolver {
private final EncryptionPolicy defaultPolicy;
private final Map<String, EncryptionPolicy> rules;
public PolicyResolver(EncryptionPolicy defaultPolicy, Map<String, EncryptionPolicy> rules) {
this.defaultPolicy = Objects.requireNonNull(defaultPolicy, "defaultPolicy");
Map<String, EncryptionPolicy> normalized = new HashMap<>();
rules.forEach((pattern, policy) -> {
String key = normalize(pattern);
if (!key.contains("@")) {
throw new IllegalArgumentException(
"Policy rule must be an address or *@domain, got: " + pattern);
}
normalized.put(key, Objects.requireNonNull(policy, pattern));
});
this.rules = Map.copyOf(normalized);
}
public EncryptionPolicy resolve(String email) {
String address = normalize(email);
EncryptionPolicy exact = rules.get(address);
if (exact != null) {
return exact;
}
String domain = address.substring(address.lastIndexOf('@') + 1);
return rules.getOrDefault("*@" + domain, defaultPolicy);
}
private static String normalize(String value) {
return value.strip().toLowerCase(Locale.ROOT);
}
}
@@ -0,0 +1,24 @@
package pro.thinhha.mailsender.pgp.delivery;
import java.util.Objects;
/**
* How one recipient will receive the message.
*
* @param encrypted whether the copy this recipient receives is encrypted
*/
public record RecipientDecision(String address, RecipientType type, EncryptionPolicy policy, RecipientKey key,
boolean encrypted) {
public RecipientDecision {
Objects.requireNonNull(address, "address");
Objects.requireNonNull(type, "type");
Objects.requireNonNull(policy, "policy");
Objects.requireNonNull(key, "key");
}
/** An OPPORTUNISTIC recipient that gets plaintext because no usable key exists. */
public boolean isDowngrade() {
return !encrypted && policy == EncryptionPolicy.OPPORTUNISTIC;
}
}
@@ -0,0 +1,37 @@
package pro.thinhha.mailsender.pgp.delivery;
import java.util.Objects;
import org.bouncycastle.openpgp.api.OpenPGPCertificate;
import pro.thinhha.mailsender.pgp.discovery.DiscoverySource;
/** Whether a recipient has a usable (discovered and validated) key. */
public sealed interface RecipientKey {
record Usable(OpenPGPCertificate certificate, DiscoverySource source) implements RecipientKey {
public Usable {
Objects.requireNonNull(certificate, "certificate");
Objects.requireNonNull(source, "source");
}
}
record Missing(Reason reason, String detail) implements RecipientKey {
public Missing {
Objects.requireNonNull(reason, "reason");
}
}
/** No lookup was made because the recipient's policy is {@link EncryptionPolicy#PLAINTEXT}. */
record NotLookedUp() implements RecipientKey {
}
enum Reason {
/** No key published. */
NOT_FOUND,
/** The lookup failed (timeout, server error, malformed response). */
UNREACHABLE,
/** Keys were found but none passed validation. */
REJECTED
}
}
@@ -0,0 +1,46 @@
package pro.thinhha.mailsender.pgp.delivery;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import org.bouncycastle.openpgp.api.OpenPGPCertificate;
import pro.thinhha.mailsender.pgp.discovery.DiscoveryResult;
import pro.thinhha.mailsender.pgp.discovery.KeyDiscovery;
import pro.thinhha.mailsender.pgp.validation.CertificateValidator;
import pro.thinhha.mailsender.pgp.validation.ValidationResult;
/** Discovers and validates a recipient's key; the first certificate that passes validation is used. */
public final class RecipientKeyResolver {
private final KeyDiscovery discovery;
private final CertificateValidator validator;
public RecipientKeyResolver(KeyDiscovery discovery, CertificateValidator validator) {
this.discovery = Objects.requireNonNull(discovery, "discovery");
this.validator = Objects.requireNonNull(validator, "validator");
}
public RecipientKey resolve(String email) {
return switch (discovery.discover(email)) {
case DiscoveryResult.Found found -> firstValid(found, email);
case DiscoveryResult.NotFound ignored -> new RecipientKey.Missing(RecipientKey.Reason.NOT_FOUND, null);
case DiscoveryResult.Unreachable u -> new RecipientKey.Missing(RecipientKey.Reason.UNREACHABLE, u.reason());
};
}
private RecipientKey firstValid(DiscoveryResult.Found found, String email) {
List<String> rejections = new ArrayList<>();
for (OpenPGPCertificate certificate : found.certificates()) {
switch (validator.validate(certificate, email)) {
case ValidationResult.Valid valid -> {
return new RecipientKey.Usable(valid.certificate(), found.source());
}
case ValidationResult.Rejected rejected ->
rejections.add(rejected.reason() + " (" + rejected.detail() + ")");
}
}
return new RecipientKey.Missing(RecipientKey.Reason.REJECTED, String.join("; ", rejections));
}
}
@@ -0,0 +1,6 @@
package pro.thinhha.mailsender.pgp.delivery;
/** Header a recipient was addressed in. */
public enum RecipientType {
TO, CC, BCC
}
@@ -0,0 +1,20 @@
package pro.thinhha.mailsender.pgp.delivery;
import java.util.Optional;
import org.bouncycastle.openpgp.api.OpenPGPCertificate;
/**
* The sender's own public key, added as an extra recipient so the sender can read the sent copy. Applications that
* need readable sent copies must provide a key for each From address; without one the copy is unreadable for the
* sender.
*/
@FunctionalInterface
public interface SenderKeyProvider {
Optional<OpenPGPCertificate> keyFor(String fromAddress);
static SenderKeyProvider none() {
return from -> Optional.empty();
}
}
@@ -0,0 +1,38 @@
package pro.thinhha.mailsender.pgp.delivery;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import org.bouncycastle.openpgp.api.OpenPGPCertificate;
import pro.thinhha.mailsender.pgp.validation.CertificateValidator;
import pro.thinhha.mailsender.pgp.validation.ValidationResult;
/** Sender keys from configuration, validated when constructed so a bad key fails at startup, not on send. */
public final class StaticSenderKeyProvider implements SenderKeyProvider {
private final Map<String, OpenPGPCertificate> keys;
public StaticSenderKeyProvider(Map<String, OpenPGPCertificate> keys, CertificateValidator validator) {
Map<String, OpenPGPCertificate> normalized = new HashMap<>();
keys.forEach((address, certificate) -> {
if (validator.validate(certificate, address) instanceof ValidationResult.Rejected rejected) {
throw new IllegalArgumentException("Sender key for " + address + " is not usable: "
+ rejected.reason() + " (" + rejected.detail() + ")");
}
normalized.put(address.strip().toLowerCase(Locale.ROOT), certificate);
});
this.keys = Map.copyOf(normalized);
}
@Override
public Optional<OpenPGPCertificate> keyFor(String fromAddress) {
return Optional.ofNullable(keys.get(fromAddress.strip().toLowerCase(Locale.ROOT)));
}
public boolean isEmpty() {
return keys.isEmpty();
}
}
@@ -0,0 +1,121 @@
package pro.thinhha.mailsender.pgp.discovery;
import java.time.Clock;
import java.time.Duration;
import java.util.Date;
import java.util.Objects;
import org.bouncycastle.openpgp.api.OpenPGPCertificate;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.github.benmanes.caffeine.cache.Expiry;
import com.github.benmanes.caffeine.cache.Ticker;
/**
* Caches lookups per address with a TTL that depends on the outcome:
* <ul>
* <li>{@link DiscoveryResult.Found}: {@code ttl}, capped at the earliest certificate expiry</li>
* <li>{@link DiscoveryResult.NotFound}: {@code negativeTtl}</li>
* <li>{@link DiscoveryResult.Unreachable}: {@code errorTtl}</li>
* </ul>
*/
public final class CachingKeyDiscovery implements KeyDiscovery {
public record Settings(Duration ttl, Duration negativeTtl, Duration errorTtl, long maxSize) {
public Settings {
requirePositive(ttl, "ttl");
requirePositive(negativeTtl, "negativeTtl");
requirePositive(errorTtl, "errorTtl");
if (maxSize <= 0) {
throw new IllegalArgumentException("maxSize must be positive");
}
}
public static Settings defaults() {
return new Settings(Duration.ofHours(24), Duration.ofHours(1), Duration.ofMinutes(5), 10_000);
}
private static void requirePositive(Duration duration, String name) {
Objects.requireNonNull(duration, name);
if (duration.isNegative() || duration.isZero()) {
throw new IllegalArgumentException(name + " must be positive");
}
}
}
private final KeyDiscovery delegate;
private final Cache<String, DiscoveryResult> cache;
public CachingKeyDiscovery(KeyDiscovery delegate, Settings settings) {
this(delegate, settings, Ticker.systemTicker(), Clock.systemUTC());
}
CachingKeyDiscovery(KeyDiscovery delegate, Settings settings, Ticker ticker, Clock clock) {
this.delegate = Objects.requireNonNull(delegate, "delegate");
this.cache = Caffeine.newBuilder()
.maximumSize(settings.maxSize())
.ticker(ticker)
.expireAfter(new ResultExpiry(settings, clock))
.build();
}
@Override
public DiscoveryResult discover(String email) {
return cache.get(StaticKeyDiscovery.normalize(email), delegate::discover);
}
/** Drops the cached result for one address, e.g. after the recipient rotated their key. */
public void evict(String email) {
cache.invalidate(StaticKeyDiscovery.normalize(email));
}
public void evictAll() {
cache.invalidateAll();
}
public long estimatedSize() {
return cache.estimatedSize();
}
private record ResultExpiry(Settings settings, Clock clock) implements Expiry<String, DiscoveryResult> {
@Override
public long expireAfterCreate(String key, DiscoveryResult value, long currentTime) {
return ttlFor(value).toNanos();
}
@Override
public long expireAfterUpdate(String key, DiscoveryResult value, long currentTime, long currentDuration) {
return ttlFor(value).toNanos();
}
@Override
public long expireAfterRead(String key, DiscoveryResult value, long currentTime, long currentDuration) {
return currentDuration;
}
private Duration ttlFor(DiscoveryResult value) {
return switch (value) {
case DiscoveryResult.Found found -> foundTtl(found);
case DiscoveryResult.NotFound ignored -> settings.negativeTtl();
case DiscoveryResult.Unreachable ignored -> settings.errorTtl();
};
}
private Duration foundTtl(DiscoveryResult.Found found) {
Duration ttl = settings.ttl();
for (OpenPGPCertificate certificate : found.certificates()) {
Date expiration = certificate.getExpirationTime();
if (expiration != null) {
Duration remaining = Duration.between(clock.instant(), expiration.toInstant());
if (remaining.compareTo(ttl) < 0) {
ttl = remaining;
}
}
}
// Already expired: keep it only briefly; validation will reject it anyway.
return ttl.isNegative() || ttl.isZero() ? settings.errorTtl() : ttl;
}
}
}
@@ -0,0 +1,23 @@
package pro.thinhha.mailsender.pgp.discovery;
import java.io.IOException;
import java.util.List;
import org.bouncycastle.openpgp.api.OpenPGPCertificate;
import org.pgpainless.PGPainless;
/** Parsing helpers for OpenPGP certificates (binary or ASCII-armored). */
public final class Certificates {
private Certificates() {
}
/**
* Parses all certificates in {@code data}. Secret keys are rejected; only public certificates are accepted.
*
* @throws IOException if the data is not a valid OpenPGP certificate collection
*/
public static List<OpenPGPCertificate> parse(byte[] data) throws IOException {
return PGPainless.getInstance().readKey().parseCertificates(data);
}
}
@@ -0,0 +1,34 @@
package pro.thinhha.mailsender.pgp.discovery;
import java.util.List;
/**
* Asks each delegate in order and returns the first {@link DiscoveryResult.Found}. If none finds a key, the result
* is {@link DiscoveryResult.Unreachable} when any delegate was unreachable (so the outage stays visible), otherwise
* {@link DiscoveryResult.NotFound}.
*/
public final class CompositeKeyDiscovery implements KeyDiscovery {
private final List<KeyDiscovery> delegates;
public CompositeKeyDiscovery(List<? extends KeyDiscovery> delegates) {
this.delegates = List.copyOf(delegates);
}
@Override
public DiscoveryResult discover(String email) {
DiscoveryResult.Unreachable unreachable = null;
for (KeyDiscovery delegate : delegates) {
DiscoveryResult result = delegate.discover(email);
switch (result) {
case DiscoveryResult.Found found -> {
return found;
}
case DiscoveryResult.Unreachable u -> unreachable = unreachable == null ? u : unreachable;
case DiscoveryResult.NotFound ignored -> {
}
}
}
return unreachable != null ? unreachable : DiscoveryResult.notFound();
}
}
@@ -0,0 +1,36 @@
package pro.thinhha.mailsender.pgp.discovery;
import java.util.List;
import java.util.Objects;
import org.bouncycastle.openpgp.api.OpenPGPCertificate;
/** Outcome of a {@link KeyDiscovery} lookup. */
public sealed interface DiscoveryResult {
/** One or more certificates were found. They still need validation before use. */
record Found(List<OpenPGPCertificate> certificates, DiscoverySource source) implements DiscoveryResult {
public Found {
certificates = List.copyOf(certificates);
if (certificates.isEmpty()) {
throw new IllegalArgumentException("Found requires at least one certificate");
}
Objects.requireNonNull(source, "source");
}
}
/** The lookup completed and authoritatively found no key. */
record NotFound() implements DiscoveryResult {
}
/** The lookup could not complete (timeout, 5xx, TLS error, malformed response, ...). */
record Unreachable(String reason) implements DiscoveryResult {
public Unreachable {
Objects.requireNonNull(reason, "reason");
}
}
static DiscoveryResult notFound() {
return new NotFound();
}
}
@@ -0,0 +1,11 @@
package pro.thinhha.mailsender.pgp.discovery;
/** Where a certificate came from. */
public enum DiscoverySource {
/** Pinned by the application via configuration. */
STATIC,
/** WKD advanced method ({@code openpgpkey.<domain>}). */
WKD_ADVANCED,
/** WKD direct method ({@code <domain>/.well-known/openpgpkey}). */
WKD_DIRECT
}
@@ -0,0 +1,22 @@
package pro.thinhha.mailsender.pgp.discovery;
import java.net.InetAddress;
import java.net.UnknownHostException;
/** Decides whether a host name exists in DNS. Used to pick the WKD method. */
@FunctionalInterface
public interface HostResolver {
boolean resolves(String host);
/** Resolves through the JVM's resolver ({@link InetAddress#getAllByName}). */
static HostResolver system() {
return host -> {
try {
return InetAddress.getAllByName(host).length > 0;
} catch (UnknownHostException e) {
return false;
}
};
}
}
@@ -0,0 +1,64 @@
package pro.thinhha.mailsender.pgp.discovery;
import java.io.IOException;
import java.io.InputStream;
import java.io.InterruptedIOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Objects;
/**
* {@link WkdFetcher} on the JDK {@link HttpClient}: no redirects, bounded timeouts and a maximum body size.
* Uses the JVM default proxy selector.
*/
public final class HttpWkdFetcher implements WkdFetcher {
private final HttpClient client;
private final Duration readTimeout;
private final long maxResponseSize;
public HttpWkdFetcher(Duration connectTimeout, Duration readTimeout, long maxResponseSize) {
this(HttpClient.newBuilder()
.connectTimeout(connectTimeout)
.followRedirects(HttpClient.Redirect.NEVER)
.build(), readTimeout, maxResponseSize);
}
HttpWkdFetcher(HttpClient client, Duration readTimeout, long maxResponseSize) {
this.client = Objects.requireNonNull(client, "client");
this.readTimeout = Objects.requireNonNull(readTimeout, "readTimeout");
if (maxResponseSize <= 0) {
throw new IllegalArgumentException("maxResponseSize must be positive");
}
this.maxResponseSize = maxResponseSize;
}
@Override
public Response fetch(URI uri) throws IOException {
HttpRequest request = HttpRequest.newBuilder(uri)
.timeout(readTimeout)
.header("Accept", "application/octet-stream")
.GET()
.build();
HttpResponse<InputStream> response;
try {
response = client.send(request, HttpResponse.BodyHandlers.ofInputStream());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new InterruptedIOException("WKD request interrupted: " + uri);
}
try (InputStream body = response.body()) {
if (response.statusCode() != 200) {
return new Response(response.statusCode(), null);
}
byte[] bytes = body.readNBytes(Math.toIntExact(Math.min(maxResponseSize + 1, Integer.MAX_VALUE)));
if (bytes.length > maxResponseSize) {
throw new IOException("WKD response from " + uri.getHost() + " exceeds " + maxResponseSize + " bytes");
}
return new Response(200, bytes);
}
}
}
@@ -0,0 +1,19 @@
package pro.thinhha.mailsender.pgp.discovery;
/**
* Finds the OpenPGP certificate(s) published for an e-mail address.
* <p>
* Implementations never throw for "no key" or network problems; those are expressed as
* {@link DiscoveryResult.NotFound} and {@link DiscoveryResult.Unreachable} so the policy layer can
* decide what to do. Certificates returned in {@link DiscoveryResult.Found} are <em>not</em> validated
* yet (expiry, revocation, encryption subkey, matching User ID).
*/
@FunctionalInterface
public interface KeyDiscovery {
/**
* @param email a bare address such as {@code alice@example.org}
* @throws IllegalArgumentException if {@code email} is not a valid address
*/
DiscoveryResult discover(String email);
}
@@ -0,0 +1,34 @@
package pro.thinhha.mailsender.pgp.discovery;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import org.bouncycastle.openpgp.api.OpenPGPCertificate;
/** Certificates pinned by the application. Addresses are matched case-insensitively. */
public final class StaticKeyDiscovery implements KeyDiscovery {
private final Map<String, List<OpenPGPCertificate>> certificates;
public StaticKeyDiscovery(Map<String, List<OpenPGPCertificate>> certificates) {
Map<String, List<OpenPGPCertificate>> normalized = new HashMap<>();
certificates.forEach((email, certs) -> {
if (!certs.isEmpty()) {
normalized.put(normalize(email), List.copyOf(certs));
}
});
this.certificates = Map.copyOf(normalized);
}
@Override
public DiscoveryResult discover(String email) {
List<OpenPGPCertificate> certs = certificates.get(normalize(email));
return certs == null ? DiscoveryResult.notFound() : new DiscoveryResult.Found(certs, DiscoverySource.STATIC);
}
static String normalize(String email) {
return email.strip().toLowerCase(Locale.ROOT);
}
}
@@ -0,0 +1,15 @@
package pro.thinhha.mailsender.pgp.discovery;
/** When to fall back from the WKD advanced method to the direct method. */
public enum WkdFallback {
/**
* Only when the {@code openpgpkey.<domain>} host does not resolve, as the WKD draft specifies.
* A 404 from an existing advanced host is final.
*/
ON_DNS_FAILURE,
/**
* Whenever the advanced method does not return a key. Finds more keys on domains with wildcard DNS
* whose advanced host answers 404 or serves a mismatching TLS certificate.
*/
ON_ANY_FAILURE
}
@@ -0,0 +1,21 @@
package pro.thinhha.mailsender.pgp.discovery;
import java.io.IOException;
import java.net.URI;
/** Performs the HTTP GET of a WKD URI. */
@FunctionalInterface
public interface WkdFetcher {
/**
* @return the HTTP status and body; the body is empty for non-200 responses
* @throws IOException on connection, TLS or timeout errors, or when the body exceeds the size limit
*/
Response fetch(URI uri) throws IOException;
record Response(int statusCode, byte[] body) {
public Response {
body = body == null ? new byte[0] : body;
}
}
}
@@ -0,0 +1,96 @@
package pro.thinhha.mailsender.pgp.discovery;
import java.io.IOException;
import java.net.URI;
import java.util.List;
import java.util.Objects;
import org.bouncycastle.openpgp.api.OpenPGPCertificate;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import pgp.wkd.WKDAddress;
import pgp.wkd.exception.MalformedUserIdException;
/**
* Web Key Directory lookup: tries the advanced method, then the direct method according to {@link WkdFallback}.
* Hashing and URL building are delegated to {@link WKDAddress} (lowercased local part, SHA-1, z-base-32,
* {@code ?l=<localpart>}).
*/
public final class WkdKeyDiscovery implements KeyDiscovery {
private static final Logger log = LoggerFactory.getLogger(WkdKeyDiscovery.class);
private final WkdFetcher fetcher;
private final HostResolver hostResolver;
private final WkdFallback fallback;
public WkdKeyDiscovery(WkdFetcher fetcher, HostResolver hostResolver, WkdFallback fallback) {
this.fetcher = Objects.requireNonNull(fetcher, "fetcher");
this.hostResolver = Objects.requireNonNull(hostResolver, "hostResolver");
this.fallback = Objects.requireNonNull(fallback, "fallback");
}
@Override
public DiscoveryResult discover(String email) {
WKDAddress address = toWkdAddress(email);
URI advanced = address.getAdvancedMethodURI();
if (!hostResolver.resolves(advanced.getHost())) {
log.debug("WKD advanced host {} does not resolve, using direct method", advanced.getHost());
return lookup(address.getDirectMethodURI(), DiscoverySource.WKD_DIRECT);
}
DiscoveryResult result = lookup(advanced, DiscoverySource.WKD_ADVANCED);
if (result instanceof DiscoveryResult.Found || fallback == WkdFallback.ON_DNS_FAILURE) {
return result;
}
log.debug("WKD advanced method gave {} for {}, trying direct method", result, address.getEmail());
return lookup(address.getDirectMethodURI(), DiscoverySource.WKD_DIRECT);
}
private DiscoveryResult lookup(URI uri, DiscoverySource source) {
WkdFetcher.Response response;
try {
response = fetcher.fetch(uri);
} catch (IOException e) {
log.debug("WKD fetch {} failed", uri, e);
return new DiscoveryResult.Unreachable(source + ": " + describe(e));
}
if (response.statusCode() == 404) {
return DiscoveryResult.notFound();
}
if (response.statusCode() != 200) {
return new DiscoveryResult.Unreachable(source + ": HTTP " + response.statusCode());
}
if (response.body().length == 0) {
return DiscoveryResult.notFound();
}
List<OpenPGPCertificate> certificates;
try {
certificates = Certificates.parse(response.body());
} catch (IOException | RuntimeException e) {
log.debug("WKD response from {} is not a valid certificate", uri, e);
return new DiscoveryResult.Unreachable(source + ": malformed certificate");
}
// The parser skips data it does not recognise, so a non-empty body without certificates is malformed too.
return certificates.isEmpty()
? new DiscoveryResult.Unreachable(source + ": malformed certificate")
: new DiscoveryResult.Found(certificates, source);
}
private static WKDAddress toWkdAddress(String email) {
try {
return WKDAddress.fromEmail(Objects.requireNonNull(email, "email").strip());
} catch (MalformedUserIdException e) {
throw new IllegalArgumentException("Not a valid e-mail address: " + email, e);
}
}
private static String describe(IOException e) {
String message = e.getMessage();
return message == null ? e.getClass().getSimpleName() : e.getClass().getSimpleName() + " " + message;
}
}
@@ -0,0 +1,22 @@
package pro.thinhha.mailsender.pgp.mime;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.ContentType;
import jakarta.mail.internet.MimeMultipart;
/**
* {@code multipart/encrypted; protocol="application/pgp-encrypted"} (RFC 3156). A plain
* {@code new MimeMultipart("encrypted")} cannot carry the {@code protocol} parameter: {@code saveChanges()}
* regenerates the Content-Type from the multipart, so the parameter has to live in the multipart itself.
*/
final class EncryptedMultipart extends MimeMultipart {
static final String PROTOCOL = "application/pgp-encrypted";
EncryptedMultipart() throws MessagingException {
super("encrypted");
ContentType type = new ContentType(contentType);
type.setParameter("protocol", PROTOCOL);
contentType = type.toString();
}
}
@@ -0,0 +1,184 @@
package pro.thinhha.mailsender.pgp.mime;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.Collection;
import java.util.Enumeration;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Properties;
import java.util.function.Supplier;
import org.bouncycastle.openpgp.api.OpenPGPCertificate;
import pro.thinhha.mailsender.pgp.crypto.PgpEncryptor;
import jakarta.activation.DataHandler;
import jakarta.mail.Header;
import jakarta.mail.MessagingException;
import jakarta.mail.Session;
import jakarta.mail.internet.ContentType;
import jakarta.mail.internet.MimeBodyPart;
import jakarta.mail.internet.MimeMessage;
import jakarta.mail.util.ByteArrayDataSource;
/**
* Turns a ready-to-send {@link MimeMessage} into a PGP/MIME message (RFC 3156).
* <p>
* The whole inner MIME entity (its {@code Content-*} headers and body, including attachments) is encrypted, not
* just the text. Envelope headers (From, To, Cc, Subject, Date, ...) are copied to the outer message unencrypted.
* <p>
* With protected headers (RFC 9788) the inner entity also carries the real From/To/Cc/Reply-To/Date/Message-ID/
* Subject and {@code protected-headers="v1"}, and the outer Subject is replaced by {@value #OBSCURED_SUBJECT}.
* Clients without support show {@value #OBSCURED_SUBJECT} as the subject, so this is off by default.
*/
public final class PgpMimeEncryptor {
private static final Session PARSE_SESSION = Session.getInstance(new Properties());
private static final byte[] VERSION_PART = "Version: 1\r\n".getBytes(StandardCharsets.US_ASCII);
public static final String OBSCURED_SUBJECT = "[...]";
private static final List<String> PROTECTED_HEADERS =
List.of("From", "To", "Cc", "Reply-To", "Date", "Message-ID", "Subject");
private final PgpEncryptor encryptor;
private final boolean protectHeaders;
public PgpMimeEncryptor(PgpEncryptor encryptor) {
this(encryptor, false);
}
public PgpMimeEncryptor(PgpEncryptor encryptor, boolean protectHeaders) {
this.encryptor = Objects.requireNonNull(encryptor, "encryptor");
this.protectHeaders = protectHeaders;
}
/**
* @param original the message as it would be sent in plaintext; it is not modified
* @param recipients certificates to encrypt to (recipients, plus the sender's own key for a readable
* sent copy)
* @param hiddenRecipients certificates encrypted to without revealing their key ID (Bcc)
* @param newMessage creates the outer message, e.g. {@code JavaMailSender::createMimeMessage}
*/
public MimeMessage encrypt(MimeMessage original, Collection<OpenPGPCertificate> recipients,
Collection<OpenPGPCertificate> hiddenRecipients, Supplier<MimeMessage> newMessage)
throws MessagingException, IOException {
MimeMessage source = snapshot(original);
byte[] ciphertext = encryptor.encrypt(innerEntity(source, protectHeaders), recipients, hiddenRecipients);
MimeMessage outer = newMessage.get();
copyEnvelopeHeaders(source, outer);
if (protectHeaders && outer.getHeader("Subject") != null) {
outer.setHeader("Subject", OBSCURED_SUBJECT);
}
outer.setContent(pgpMimeBody(ciphertext));
outer.saveChanges();
return outer;
}
/**
* Serializes and re-parses the message so headers are final and the encoded body is available. Keeps the
* caller's Message-ID, which {@code saveChanges()} would otherwise regenerate.
*/
private static MimeMessage snapshot(MimeMessage original) throws MessagingException, IOException {
String messageId = original.getMessageID();
original.saveChanges();
if (messageId != null) {
original.setHeader("Message-ID", messageId);
}
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
original.writeTo(buffer);
return new MimeMessage(PARSE_SESSION, new ByteArrayInputStream(buffer.toByteArray()));
}
/**
* The {@code Content-*} headers (plus the protected headers, if enabled) and the still-encoded body, with CRLF
* line endings.
*/
static byte[] innerEntity(MimeMessage source, boolean protectHeaders) throws MessagingException, IOException {
ByteArrayOutputStream entity = new ByteArrayOutputStream();
if (protectHeaders) {
for (String name : PROTECTED_HEADERS) {
String[] values = source.getHeader(name);
if (values != null) {
for (String value : values) {
writeHeader(entity, name + ": " + value);
}
}
}
}
Enumeration<String> lines = source.getAllHeaderLines();
while (lines.hasMoreElements()) {
String line = lines.nextElement();
if (protectHeaders && line.regionMatches(true, 0, "Content-Type:", 0, "Content-Type:".length())) {
ContentType type = new ContentType(source.getContentType());
type.setParameter("protected-headers", "v1");
writeHeader(entity, "Content-Type: " + type);
} else if (isContentHeader(line)) {
writeHeader(entity, line);
}
}
entity.write('\r');
entity.write('\n');
try (InputStream body = source.getRawInputStream()) {
body.transferTo(entity);
}
return toCrlf(entity.toByteArray());
}
private static void writeHeader(ByteArrayOutputStream entity, String line) throws IOException {
entity.write(line.getBytes(StandardCharsets.UTF_8));
entity.write('\r');
entity.write('\n');
}
private static void copyEnvelopeHeaders(MimeMessage source, MimeMessage outer) throws MessagingException {
Enumeration<Header> headers = source.getAllHeaders();
while (headers.hasMoreElements()) {
Header header = headers.nextElement();
String name = header.getName().toLowerCase(Locale.ROOT);
if (!name.startsWith("content-") && !name.equals("mime-version") && !name.equals("message-id")) {
outer.addHeader(header.getName(), header.getValue());
}
}
}
private static EncryptedMultipart pgpMimeBody(byte[] ciphertext) throws MessagingException {
MimeBodyPart control = new MimeBodyPart();
control.setDataHandler(new DataHandler(new ByteArrayDataSource(VERSION_PART, EncryptedMultipart.PROTOCOL)));
control.setHeader("Content-Description", "PGP/MIME version identification");
control.setHeader("Content-Transfer-Encoding", "7bit");
MimeBodyPart encrypted = new MimeBodyPart();
encrypted.setDataHandler(new DataHandler(
new ByteArrayDataSource(ciphertext, "application/octet-stream; name=\"encrypted.asc\"")));
encrypted.setHeader("Content-Description", "OpenPGP encrypted message");
encrypted.setHeader("Content-Disposition", "inline; filename=\"encrypted.asc\"");
encrypted.setHeader("Content-Transfer-Encoding", "7bit");
EncryptedMultipart multipart = new EncryptedMultipart();
multipart.setPreamble("This is an OpenPGP/MIME encrypted message (RFC 4880 and 3156)");
multipart.addBodyPart(control);
multipart.addBodyPart(encrypted);
return multipart;
}
private static boolean isContentHeader(String headerLine) {
return headerLine.regionMatches(true, 0, "content-", 0, "content-".length());
}
/** RFC 3156 requires the encrypted entity in canonical form: every line ends in CRLF. */
static byte[] toCrlf(byte[] data) {
ByteArrayOutputStream out = new ByteArrayOutputStream(data.length + data.length / 50);
for (int i = 0; i < data.length; i++) {
byte b = data[i];
if (b == '\n' && (i == 0 || data[i - 1] != '\r')) {
out.write('\r');
}
out.write(b);
}
return out.toByteArray();
}
}
@@ -0,0 +1,100 @@
package pro.thinhha.mailsender.pgp.validation;
import java.time.Clock;
import java.util.Date;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import org.bouncycastle.openpgp.api.OpenPGPCertificate;
import org.bouncycastle.openpgp.api.OpenPGPCertificate.OpenPGPComponentKey;
import org.pgpainless.PGPainless;
import org.pgpainless.algorithm.EncryptionPurpose;
import org.pgpainless.key.info.KeyRingInfo;
import org.pgpainless.policy.Policy;
/**
* Decides whether a discovered certificate may be used to encrypt mail to an address. A certificate is
* {@link ValidationResult.Valid} only if its primary key is neither revoked nor expired, a valid User ID contains
* the address, and at least one valid subkey for encrypting communications passes the algorithm policy.
*/
public final class CertificateValidator {
private final PGPainless api;
private final Clock clock;
public CertificateValidator() {
this(PGPainless.getInstance(), Clock.systemUTC());
}
public CertificateValidator(PGPainless api, Clock clock) {
this.api = Objects.requireNonNull(api, "api");
this.clock = Objects.requireNonNull(clock, "clock");
}
public ValidationResult validate(OpenPGPCertificate certificate, String email) {
Date now = Date.from(clock.instant());
try {
KeyRingInfo info = api.inspect(certificate, now);
// PGPainless and Bouncy Castle can disagree on expiry after it was changed; trust the earlier one.
Date expiration = earliest(info.getPrimaryKeyExpirationDate(), certificate.getExpirationTime(now));
if (expiration != null && !expiration.after(now)) {
return reject(certificate, ValidationResult.Reason.EXPIRED, "expired " + expiration.toInstant());
}
if (!info.getRevocationState().isNotRevoked() || !certificate.getPrimaryKey().isBoundAt(now)) {
return reject(certificate, ValidationResult.Reason.REVOKED, "primary key revoked");
}
if (!hasUserIdFor(info.getValidUserIds(), email)) {
return reject(certificate, ValidationResult.Reason.USER_ID_MISMATCH,
"no valid user ID for " + email + " in " + info.getUserIds());
}
List<OpenPGPComponentKey> encryptionKeys = info.getEncryptionSubkeys(EncryptionPurpose.COMMUNICATIONS);
if (encryptionKeys.isEmpty()) {
return reject(certificate, ValidationResult.Reason.NO_ENCRYPTION_SUBKEY,
"no valid subkey for encrypting communications");
}
Policy.PublicKeyAlgorithmPolicy algorithmPolicy = api.getAlgorithmPolicy().getPublicKeyAlgorithmPolicy();
boolean acceptable = encryptionKeys.stream().anyMatch(key ->
algorithmPolicy.isAcceptable(key.getAlgorithm(), key.getPGPPublicKey().getBitStrength()));
if (!acceptable) {
return reject(certificate, ValidationResult.Reason.WEAK_ALGORITHM,
"encryption subkeys rejected by algorithm policy");
}
return new ValidationResult.Valid(certificate);
} catch (RuntimeException e) {
return reject(certificate, ValidationResult.Reason.INVALID, String.valueOf(e.getMessage()));
}
}
/**
* Matches {@code email} against the address in each User ID, either the {@code <...>} part of
* {@code Name <addr>} or the whole User ID. Comparison ignores case: WKD already lowercases the local part.
*/
static boolean hasUserIdFor(List<String> userIds, String email) {
String wanted = email.strip().toLowerCase(Locale.ROOT);
return userIds.stream()
.map(CertificateValidator::addressOf)
.anyMatch(wanted::equals);
}
private static String addressOf(String userId) {
int open = userId.lastIndexOf('<');
int close = userId.lastIndexOf('>');
String address = open >= 0 && close > open ? userId.substring(open + 1, close) : userId;
return address.strip().toLowerCase(Locale.ROOT);
}
private static Date earliest(Date a, Date b) {
if (a == null) {
return b;
}
return b == null || a.before(b) ? a : b;
}
private static ValidationResult reject(OpenPGPCertificate certificate, ValidationResult.Reason reason,
String detail) {
return new ValidationResult.Rejected(certificate, reason, detail);
}
}
@@ -0,0 +1,40 @@
package pro.thinhha.mailsender.pgp.validation;
import java.util.Objects;
import org.bouncycastle.openpgp.api.OpenPGPCertificate;
/** Outcome of {@link CertificateValidator#validate}. */
public sealed interface ValidationResult {
OpenPGPCertificate certificate();
/** The certificate can be used to encrypt mail to the address it was validated for. */
record Valid(OpenPGPCertificate certificate) implements ValidationResult {
public Valid {
Objects.requireNonNull(certificate, "certificate");
}
}
record Rejected(OpenPGPCertificate certificate, Reason reason, String detail) implements ValidationResult {
public Rejected {
Objects.requireNonNull(certificate, "certificate");
Objects.requireNonNull(reason, "reason");
}
}
enum Reason {
/** The primary key is revoked. */
REVOKED,
/** The primary key is expired. */
EXPIRED,
/** No valid User ID carries the address. */
USER_ID_MISMATCH,
/** No valid, non-expired subkey is marked for encrypting communications. */
NO_ENCRYPTION_SUBKEY,
/** Encryption subkeys exist, but their algorithm or size is rejected by the algorithm policy. */
WEAK_ALGORITHM,
/** The certificate could not be evaluated (bad self-signatures, unsupported packets, ...). */
INVALID
}
}
@@ -0,0 +1,57 @@
package pro.thinhha.mailsender.pgp.crypto;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.List;
import org.bouncycastle.openpgp.api.OpenPGPKey;
import org.junit.jupiter.api.Test;
import org.pgpainless.PGPainless;
import org.pgpainless.decryption_verification.ConsumerOptions;
import org.pgpainless.decryption_verification.DecryptionStream;
public class PgpEncryptorTest {
private static final PGPainless API = PGPainless.getInstance();
private static final OpenPGPKey ALICE = API.generateKey().modernKeyRing("Alice <alice@example.org>");
private static final OpenPGPKey BOB = API.generateKey().modernKeyRing("Bob <bob@example.org>");
private static final OpenPGPKey CAROL = API.generateKey().modernKeyRing("Carol <carol@example.org>");
private final PgpEncryptor encryptor = new PgpEncryptor();
@Test
void everyRecipientCanDecrypt() throws Exception {
byte[] plaintext = "Hello, PGP/MIME\r\n".getBytes(StandardCharsets.UTF_8);
byte[] armored = encryptor.encrypt(plaintext, List.of(ALICE.toCertificate(), BOB.toCertificate()),
List.of(CAROL.toCertificate()));
String text = new String(armored, StandardCharsets.US_ASCII);
assertThat(text).startsWith("-----BEGIN PGP MESSAGE-----").doesNotContain("Version:");
assertThat(decrypt(armored, ALICE)).isEqualTo(plaintext);
assertThat(decrypt(armored, BOB)).isEqualTo(plaintext);
assertThat(decrypt(armored, CAROL)).isEqualTo(plaintext);
}
@Test
void requiresARecipient() {
assertThatIllegalArgumentException().isThrownBy(() -> encryptor.encrypt(new byte[1], List.of(), List.of()));
}
public static byte[] decrypt(byte[] message, OpenPGPKey key) throws Exception {
ByteArrayOutputStream out = new ByteArrayOutputStream();
try (DecryptionStream in = API.processMessage()
.onInputStream(new ByteArrayInputStream(message))
.withOptions(ConsumerOptions.get(API).addDecryptionKey(key))) {
in.transferTo(out);
} catch (IOException e) {
throw e;
}
return out.toByteArray();
}
}
@@ -0,0 +1,167 @@
package pro.thinhha.mailsender.pgp.delivery;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.bouncycastle.openpgp.api.OpenPGPCertificate;
import org.junit.jupiter.api.Test;
import org.pgpainless.PGPainless;
import jakarta.mail.internet.InternetAddress;
import pro.thinhha.mailsender.pgp.discovery.DiscoveryResult;
import pro.thinhha.mailsender.pgp.discovery.DiscoverySource;
import pro.thinhha.mailsender.pgp.discovery.KeyDiscovery;
import pro.thinhha.mailsender.pgp.validation.CertificateValidator;
class DeliveryPlannerTest {
private static final OpenPGPCertificate ALICE = cert("alice@example.org");
private static final OpenPGPCertificate BOB = cert("bob@example.org");
private static final OpenPGPCertificate DAVE = cert("dave@example.org");
private final Map<String, DiscoveryResult> published = new HashMap<>(Map.of(
"alice@example.org", found(ALICE),
"bob@example.org", found(BOB),
"dave@example.org", found(DAVE)));
private final Map<String, EncryptionPolicy> rules = new HashMap<>();
private int lookups;
@Test
void everyoneWithKeysGetsOneEncryptedCopy() {
DeliveryPlan plan = planner(MixedMode.SPLIT).plan(list("alice@example.org"), list("bob@example.org"), list());
assertThat(plan.deliveries()).singleElement().satisfies(d -> {
assertThat(d.encrypted()).isTrue();
assertThat(d.hiddenRecipients()).isFalse();
assertThat(d.envelope()).extracting(InternetAddress::getAddress)
.containsExactly("alice@example.org", "bob@example.org");
assertThat(d.recipientKeys()).containsExactly(ALICE, BOB);
});
assertThat(plan.decisions()).allMatch(RecipientDecision::encrypted);
}
@Test
void nobodyWithKeysGetsOnePlaintextCopyIncludingBcc() {
DeliveryPlan plan = planner(MixedMode.FAIL)
.plan(list("carol@example.org"), list(), list("erin@example.org"));
assertThat(plan.deliveries()).singleElement().satisfies(d -> {
assertThat(d.encrypted()).isFalse();
assertThat(d.envelope()).extracting(InternetAddress::getAddress)
.containsExactly("carol@example.org", "erin@example.org");
});
assertThat(plan.decisions()).allMatch(RecipientDecision::isDowngrade);
}
@Test
void mixedRecipientsAreSplit() {
DeliveryPlan plan = planner(MixedMode.SPLIT).plan(
list("alice@example.org", "carol@example.org"), list(), list("dave@example.org", "erin@example.org"));
assertThat(plan.deliveries()).hasSize(4);
Delivery encrypted = plan.deliveries().get(0);
assertThat(encrypted.encrypted()).isTrue();
assertThat(encrypted.envelope()).extracting(InternetAddress::getAddress).containsExactly("alice@example.org");
Delivery plain = plan.deliveries().get(1);
assertThat(plain.encrypted()).isFalse();
assertThat(plain.envelope()).extracting(InternetAddress::getAddress).containsExactly("carol@example.org");
Delivery bccWithKey = plan.deliveries().get(2);
assertThat(bccWithKey.encrypted()).isTrue();
assertThat(bccWithKey.hiddenRecipients()).isTrue();
assertThat(bccWithKey.recipientKeys()).containsExactly(DAVE);
assertThat(bccWithKey.envelope()).extracting(InternetAddress::getAddress).containsExactly("dave@example.org");
Delivery bccWithoutKey = plan.deliveries().get(3);
assertThat(bccWithoutKey.encrypted()).isFalse();
assertThat(bccWithoutKey.envelope()).extracting(InternetAddress::getAddress)
.containsExactly("erin@example.org");
}
@Test
void mixedRecipientsAreRefusedInFailMode() {
assertThatThrownBy(() -> planner(MixedMode.FAIL).plan(
list("alice@example.org", "carol@example.org"), list(), list()))
.isInstanceOfSatisfying(EncryptionPolicyException.class,
e -> assertThat(e.addresses()).containsExactly("carol@example.org"));
}
@Test
void requiredRecipientWithoutKeyRefusesWholeMessage() {
rules.put("*@example.org", EncryptionPolicy.REQUIRE);
assertThatThrownBy(() -> planner(MixedMode.SPLIT).plan(
list("alice@example.org", "carol@example.org"), list(), list()))
.isInstanceOfSatisfying(EncryptionPolicyException.class,
e -> assertThat(e.addresses()).containsExactly("carol@example.org"));
}
@Test
void plaintextPolicySkipsLookupAndGetsPlaintextCopy() {
rules.put("bob@example.org", EncryptionPolicy.PLAINTEXT);
DeliveryPlan plan = planner(MixedMode.SPLIT).plan(list("alice@example.org", "bob@example.org"), list(), list());
assertThat(lookups).isEqualTo(1);
assertThat(plan.deliveries()).hasSize(2);
RecipientDecision bob = plan.decisions().get(1);
assertThat(bob.key()).isInstanceOf(RecipientKey.NotLookedUp.class);
assertThat(bob.encrypted()).isFalse();
assertThat(bob.isDowngrade()).isFalse();
}
@Test
void invalidKeyCountsAsMissing() {
published.put("carol@example.org", found(cert("mallory@example.org")));
DeliveryPlan plan = planner(MixedMode.SPLIT).plan(list("carol@example.org"), list(), list());
assertThat(plan.decisions()).singleElement().satisfies(d -> {
assertThat(d.encrypted()).isFalse();
assertThat(d.key()).isInstanceOfSatisfying(RecipientKey.Missing.class,
m -> assertThat(m.reason()).isEqualTo(RecipientKey.Reason.REJECTED));
});
}
@Test
void duplicateAddressGetsOneCopy() {
DeliveryPlan plan = planner(MixedMode.SPLIT)
.plan(list("alice@example.org"), list("Alice@Example.org"), list("alice@example.org"));
assertThat(plan.decisions()).singleElement().extracting(RecipientDecision::type).isEqualTo(RecipientType.TO);
assertThat(plan.deliveries()).singleElement()
.satisfies(d -> assertThat(d.envelope()).hasSize(1));
}
private DeliveryPlanner planner(MixedMode mode) {
KeyDiscovery discovery = email -> {
lookups++;
return published.getOrDefault(email.toLowerCase(), DiscoveryResult.notFound());
};
return new DeliveryPlanner(new PolicyResolver(EncryptionPolicy.OPPORTUNISTIC, rules),
new RecipientKeyResolver(discovery, new CertificateValidator()), mode);
}
private static List<InternetAddress> list(String... addresses) {
return java.util.Arrays.stream(addresses).map(a -> {
try {
return new InternetAddress(a);
} catch (jakarta.mail.internet.AddressException e) {
throw new IllegalArgumentException(e);
}
}).toList();
}
private static DiscoveryResult found(OpenPGPCertificate certificate) {
return new DiscoveryResult.Found(List.of(certificate), DiscoverySource.WKD_ADVANCED);
}
private static OpenPGPCertificate cert(String email) {
return PGPainless.getInstance().generateKey().modernKeyRing("<" + email + ">").toCertificate();
}
}
@@ -0,0 +1,93 @@
package pro.thinhha.mailsender.pgp.delivery;
import static org.assertj.core.api.Assertions.assertThat;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Properties;
import org.bouncycastle.openpgp.api.OpenPGPKey;
import org.junit.jupiter.api.Test;
import org.pgpainless.PGPainless;
import jakarta.mail.Message;
import jakarta.mail.Multipart;
import jakarta.mail.Session;
import jakarta.mail.internet.InternetAddress;
import jakarta.mail.internet.MimeMessage;
import pro.thinhha.mailsender.pgp.crypto.PgpEncryptor;
import pro.thinhha.mailsender.pgp.crypto.PgpEncryptorTest;
import pro.thinhha.mailsender.pgp.mime.PgpMimeEncryptor;
import pro.thinhha.mailsender.pgp.validation.CertificateValidator;
class MessageAssemblerTest {
private static final PGPainless API = PGPainless.getInstance();
private static final OpenPGPKey ALICE = API.generateKey().modernKeyRing("<alice@example.org>");
private static final OpenPGPKey DAVE = API.generateKey().modernKeyRing("<dave@example.org>");
private static final OpenPGPKey SENDER = API.generateKey().modernKeyRing("<sender@example.com>");
private static final Session SESSION = Session.getInstance(new Properties());
@Test
void buildsEnvelopeLimitedCopiesThatShareHeaders() throws Exception {
MimeMessage original = new MimeMessage(SESSION);
original.setFrom("sender@example.com");
original.setRecipients(Message.RecipientType.TO, "alice@example.org, carol@example.org");
original.setRecipients(Message.RecipientType.BCC, "dave@example.org");
original.setSubject("Split");
original.setText("secret");
DeliveryPlan plan = new DeliveryPlan(List.of(
new Delivery(List.of(new InternetAddress("alice@example.org")), List.of(ALICE.toCertificate()), false),
Delivery.plaintext(List.of(new InternetAddress("carol@example.org"))),
new Delivery(List.of(new InternetAddress("dave@example.org")), List.of(DAVE.toCertificate()), true)),
List.of());
SenderKeyProvider senderKeys = new StaticSenderKeyProvider(
Map.of("Sender@Example.com", SENDER.toCertificate()), new CertificateValidator());
List<MimeMessage> messages = new MessageAssembler(new PgpMimeEncryptor(new PgpEncryptor()), senderKeys)
.assemble(original, plan, () -> new MimeMessage(SESSION));
assertThat(messages).hasSize(3);
assertThat(messages).extracting(MimeMessage::getMessageID).doesNotContainNull().containsOnly(
messages.get(0).getMessageID());
for (MimeMessage message : messages) {
assertThat(message.getHeader("Bcc")).isNull();
assertThat(message.getRecipients(Message.RecipientType.TO)).hasSize(2);
assertThat(message.getSubject()).isEqualTo("Split");
}
MimeMessage toAlice = messages.get(0);
assertThat(toAlice.getAllRecipients()).extracting(Object::toString).containsExactly("alice@example.org");
assertThat(toAlice.isMimeType("multipart/encrypted")).isTrue();
byte[] armored = encryptedPart(toAlice);
assertThat(new String(PgpEncryptorTest.decrypt(armored, ALICE))).contains("secret");
assertThat(new String(PgpEncryptorTest.decrypt(armored, SENDER))).as("readable sent copy").contains("secret");
MimeMessage toCarol = messages.get(1);
assertThat(toCarol.getAllRecipients()).extracting(Object::toString).containsExactly("carol@example.org");
assertThat(toCarol.getContent()).isEqualTo("secret");
MimeMessage toDave = messages.get(2);
assertThat(toDave.getAllRecipients()).extracting(Object::toString).containsExactly("dave@example.org");
assertThat(new String(PgpEncryptorTest.decrypt(encryptedPart(toDave), DAVE))).contains("secret");
}
@Test
void staticSenderKeyProviderRejectsUnusableKey() {
org.assertj.core.api.Assertions.assertThatIllegalArgumentException().isThrownBy(() ->
new StaticSenderKeyProvider(Map.of("other@example.com", SENDER.toCertificate()),
new CertificateValidator()));
assertThat(SenderKeyProvider.none().keyFor("x@y.z")).isEqualTo(Optional.empty());
}
private static byte[] encryptedPart(MimeMessage message) throws Exception {
ByteArrayOutputStream out = new ByteArrayOutputStream();
message.writeTo(out);
MimeMessage parsed = new MimeMessage(SESSION, new ByteArrayInputStream(out.toByteArray()));
return ((Multipart) parsed.getContent()).getBodyPart(1).getInputStream().readAllBytes();
}
}
@@ -0,0 +1,35 @@
package pro.thinhha.mailsender.pgp.delivery;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
import java.util.Map;
import org.junit.jupiter.api.Test;
class PolicyResolverTest {
private final PolicyResolver resolver = new PolicyResolver(EncryptionPolicy.OPPORTUNISTIC, Map.of(
"*@partner.com", EncryptionPolicy.REQUIRE,
"Press@Partner.com", EncryptionPolicy.PLAINTEXT,
"*@newsletter.example", EncryptionPolicy.PLAINTEXT));
@Test
void exactAddressWinsOverDomainWinsOverDefault() {
assertThat(resolver.resolve("press@partner.com")).isEqualTo(EncryptionPolicy.PLAINTEXT);
assertThat(resolver.resolve("CEO@PARTNER.COM")).isEqualTo(EncryptionPolicy.REQUIRE);
assertThat(resolver.resolve("someone@newsletter.example")).isEqualTo(EncryptionPolicy.PLAINTEXT);
assertThat(resolver.resolve("alice@example.org")).isEqualTo(EncryptionPolicy.OPPORTUNISTIC);
}
@Test
void domainRuleDoesNotMatchSubdomains() {
assertThat(resolver.resolve("a@sub.partner.com")).isEqualTo(EncryptionPolicy.OPPORTUNISTIC);
}
@Test
void rejectsRuleWithoutAt() {
assertThatIllegalArgumentException().isThrownBy(() ->
new PolicyResolver(EncryptionPolicy.OPPORTUNISTIC, Map.of("partner.com", EncryptionPolicy.REQUIRE)));
}
}
@@ -0,0 +1,109 @@
package pro.thinhha.mailsender.pgp.discovery;
import static org.assertj.core.api.Assertions.assertThat;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.Date;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import org.bouncycastle.openpgp.api.OpenPGPCertificate;
import org.junit.jupiter.api.Test;
class CachingKeyDiscoveryTest {
private static final CachingKeyDiscovery.Settings SETTINGS = new CachingKeyDiscovery.Settings(
Duration.ofHours(24), Duration.ofHours(1), Duration.ofMinutes(5), 100);
private final AtomicLong nanos = new AtomicLong();
private final AtomicInteger calls = new AtomicInteger();
private final Instant now = Instant.now();
@Test
void cachesFoundUntilTtl() {
CachingKeyDiscovery cache = cache(found(TestKeys.certificate("Alice <alice@example.org>")));
cache.discover("alice@example.org");
advance(Duration.ofHours(23));
cache.discover("ALICE@example.org");
assertThat(calls).hasValue(1);
advance(Duration.ofHours(2));
cache.discover("alice@example.org");
assertThat(calls).hasValue(2);
}
@Test
void cachesNotFoundForNegativeTtl() {
CachingKeyDiscovery cache = cache(DiscoveryResult.notFound());
cache.discover("bob@example.org");
advance(Duration.ofMinutes(59));
cache.discover("bob@example.org");
assertThat(calls).hasValue(1);
advance(Duration.ofMinutes(2));
cache.discover("bob@example.org");
assertThat(calls).hasValue(2);
}
@Test
void cachesUnreachableForErrorTtl() {
CachingKeyDiscovery cache = cache(new DiscoveryResult.Unreachable("timeout"));
cache.discover("bob@example.org");
advance(Duration.ofMinutes(4));
cache.discover("bob@example.org");
assertThat(calls).hasValue(1);
advance(Duration.ofMinutes(2));
cache.discover("bob@example.org");
assertThat(calls).hasValue(2);
}
@Test
void capsTtlAtCertificateExpiry() {
Date expiry = Date.from(now.plus(Duration.ofHours(2)));
CachingKeyDiscovery cache = cache(found(TestKeys.certificateExpiringAt("Alice <alice@example.org>", expiry)));
cache.discover("alice@example.org");
advance(Duration.ofMinutes(119));
cache.discover("alice@example.org");
assertThat(calls).hasValue(1);
advance(Duration.ofMinutes(2));
cache.discover("alice@example.org");
assertThat(calls).hasValue(2);
}
@Test
void evictForcesNewLookup() {
CachingKeyDiscovery cache = cache(DiscoveryResult.notFound());
cache.discover("bob@example.org");
cache.evict("Bob@Example.org");
cache.discover("bob@example.org");
assertThat(calls).hasValue(2);
}
private CachingKeyDiscovery cache(DiscoveryResult result) {
KeyDiscovery delegate = email -> {
calls.incrementAndGet();
return result;
};
return new CachingKeyDiscovery(delegate, SETTINGS, nanos::get, Clock.fixed(now, ZoneOffset.UTC));
}
private void advance(Duration duration) {
nanos.addAndGet(duration.toNanos());
}
private static DiscoveryResult found(OpenPGPCertificate certificate) {
return new DiscoveryResult.Found(List.of(certificate), DiscoverySource.WKD_ADVANCED);
}
}
@@ -0,0 +1,44 @@
package pro.thinhha.mailsender.pgp.discovery;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.List;
import java.util.Map;
import org.bouncycastle.openpgp.api.OpenPGPCertificate;
import org.junit.jupiter.api.Test;
class CompositeKeyDiscoveryTest {
private static final OpenPGPCertificate BOB = TestKeys.certificate("Bob <bob@legacy.org>");
private final StaticKeyDiscovery pinned = new StaticKeyDiscovery(Map.of("Bob@Legacy.org", List.of(BOB)));
@Test
void staticKeyWinsWithoutAskingLaterDelegates() {
KeyDiscovery wkd = email -> {
throw new AssertionError("WKD must not be queried");
};
DiscoveryResult result = new CompositeKeyDiscovery(List.of(pinned, wkd)).discover("bob@legacy.org");
assertThat(result).isInstanceOfSatisfying(DiscoveryResult.Found.class,
found -> assertThat(found.source()).isEqualTo(DiscoverySource.STATIC));
}
@Test
void reportsUnreachableWhenNoDelegateFoundAKey() {
KeyDiscovery wkd = email -> new DiscoveryResult.Unreachable("timeout");
assertThat(new CompositeKeyDiscovery(List.of(pinned, wkd)).discover("carol@example.org"))
.isInstanceOf(DiscoveryResult.Unreachable.class);
}
@Test
void reportsNotFoundWhenAllDelegatesAnsweredNotFound() {
KeyDiscovery wkd = email -> DiscoveryResult.notFound();
assertThat(new CompositeKeyDiscovery(List.of(pinned, wkd)).discover("carol@example.org"))
.isInstanceOf(DiscoveryResult.NotFound.class);
}
}
@@ -0,0 +1,85 @@
package pro.thinhha.mailsender.pgp.discovery;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIOException;
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.URI;
import java.time.Duration;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import com.sun.net.httpserver.HttpServer;
class HttpWkdFetcherTest {
private HttpServer server;
private final AtomicReference<String> lastQuery = new AtomicReference<>();
private final HttpWkdFetcher fetcher = new HttpWkdFetcher(Duration.ofSeconds(2), Duration.ofSeconds(2), 1024);
@BeforeEach
void start() throws IOException {
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/key", exchange -> {
lastQuery.set(exchange.getRequestURI().getRawQuery());
respond(exchange, 200, new byte[] {1, 2, 3});
});
server.createContext("/missing", exchange -> respond(exchange, 404, new byte[0]));
server.createContext("/big", exchange -> respond(exchange, 200, new byte[2048]));
server.createContext("/redirect", exchange -> {
exchange.getResponseHeaders().add("Location", "/key");
respond(exchange, 302, new byte[0]);
});
server.start();
}
@AfterEach
void stop() {
server.stop(0);
}
@Test
void returnsBodyOn200() throws IOException {
WkdFetcher.Response response = fetcher.fetch(uri("/key?l=alice"));
assertThat(response.statusCode()).isEqualTo(200);
assertThat(response.body()).containsExactly(1, 2, 3);
assertThat(lastQuery.get()).isEqualTo("l=alice");
}
@Test
void returnsStatusWithoutBodyOnNon200() throws IOException {
WkdFetcher.Response response = fetcher.fetch(uri("/missing"));
assertThat(response.statusCode()).isEqualTo(404);
assertThat(response.body()).isEmpty();
}
@Test
void doesNotFollowRedirects() throws IOException {
assertThat(fetcher.fetch(uri("/redirect")).statusCode()).isEqualTo(302);
assertThat(lastQuery.get()).isNull();
}
@Test
void rejectsOversizedBody() {
assertThatIOException().isThrownBy(() -> fetcher.fetch(uri("/big"))).withMessageContaining("exceeds 1024");
}
private URI uri(String pathAndQuery) {
return URI.create("http://127.0.0.1:" + server.getAddress().getPort() + pathAndQuery);
}
private static void respond(com.sun.net.httpserver.HttpExchange exchange, int status, byte[] body)
throws IOException {
exchange.sendResponseHeaders(status, body.length == 0 ? -1 : body.length);
try (OutputStream out = exchange.getResponseBody()) {
out.write(body);
}
}
}
@@ -0,0 +1,37 @@
package pro.thinhha.mailsender.pgp.discovery;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.util.Date;
import org.bouncycastle.openpgp.api.OpenPGPCertificate;
import org.bouncycastle.openpgp.api.OpenPGPKey;
import org.pgpainless.PGPainless;
import org.pgpainless.key.protection.SecretKeyRingProtector;
final class TestKeys {
private TestKeys() {
}
static OpenPGPCertificate certificate(String userId) {
return PGPainless.getInstance().generateKey().modernKeyRing(userId).toCertificate();
}
static OpenPGPCertificate certificateExpiringAt(String userId, Date expiration) {
PGPainless api = PGPainless.getInstance();
OpenPGPKey key = api.generateKey().modernKeyRing(userId);
return api.modify(key)
.setExpirationDate(expiration, SecretKeyRingProtector.unprotectedKeys())
.done()
.toCertificate();
}
static byte[] encoded(OpenPGPCertificate certificate) {
try {
return certificate.getEncoded();
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
}
@@ -0,0 +1,21 @@
package pro.thinhha.mailsender.pgp.discovery;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
import pgp.wkd.WKDAddress;
/** Pins the behaviour we rely on from wkd-java against the test vector in the WKD draft. */
class WkdAddressTest {
@Test
void buildsDraftTestVectorUris() throws Exception {
WKDAddress address = WKDAddress.fromEmail("Joe.Doe@Example.ORG");
assertThat(address.getAdvancedMethodURI()).hasToString(
"https://openpgpkey.example.org/.well-known/openpgpkey/example.org/hu/iy9q119eutrkn8s1mk4r39qejnbu3n5q?l=Joe.Doe");
assertThat(address.getDirectMethodURI()).hasToString(
"https://example.org/.well-known/openpgpkey/hu/iy9q119eutrkn8s1mk4r39qejnbu3n5q?l=Joe.Doe");
}
}
@@ -0,0 +1,160 @@
package pro.thinhha.mailsender.pgp.discovery;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
import java.io.IOException;
import java.net.SocketTimeoutException;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.bouncycastle.openpgp.api.OpenPGPCertificate;
import org.junit.jupiter.api.Test;
class WkdKeyDiscoveryTest {
private static final String EMAIL = "alice@example.org";
private static final OpenPGPCertificate ALICE = TestKeys.certificate("Alice <alice@example.org>");
private final FakeFetcher fetcher = new FakeFetcher();
@Test
void usesAdvancedMethodWhenItsHostResolves() {
fetcher.respond("openpgpkey.example.org", 200, TestKeys.encoded(ALICE));
DiscoveryResult result = discovery(true, WkdFallback.ON_DNS_FAILURE).discover(EMAIL);
assertThat(result).isInstanceOfSatisfying(DiscoveryResult.Found.class, found -> {
assertThat(found.source()).isEqualTo(DiscoverySource.WKD_ADVANCED);
assertThat(found.certificates()).singleElement()
.extracting(OpenPGPCertificate::getKeyIdentifier).isEqualTo(ALICE.getKeyIdentifier());
});
assertThat(fetcher.requestedHosts()).containsExactly("openpgpkey.example.org");
}
@Test
void requestsLowercasedHashAndOriginalLocalPart() {
fetcher.respond("openpgpkey.example.org", 404, null);
discovery(true, WkdFallback.ON_DNS_FAILURE).discover("Joe.Doe@Example.ORG");
assertThat(fetcher.requested).singleElement().hasToString(
"https://openpgpkey.example.org/.well-known/openpgpkey/example.org/hu/iy9q119eutrkn8s1mk4r39qejnbu3n5q?l=Joe.Doe");
}
@Test
void fallsBackToDirectMethodWhenAdvancedHostDoesNotResolve() {
fetcher.respond("example.org", 200, TestKeys.encoded(ALICE));
DiscoveryResult result = discovery(false, WkdFallback.ON_DNS_FAILURE).discover(EMAIL);
assertThat(result).isInstanceOfSatisfying(DiscoveryResult.Found.class,
found -> assertThat(found.source()).isEqualTo(DiscoverySource.WKD_DIRECT));
assertThat(fetcher.requestedHosts()).containsExactly("example.org");
}
@Test
void advanced404IsFinalWhenFallingBackOnlyOnDnsFailure() {
fetcher.respond("openpgpkey.example.org", 404, null);
fetcher.respond("example.org", 200, TestKeys.encoded(ALICE));
DiscoveryResult result = discovery(true, WkdFallback.ON_DNS_FAILURE).discover(EMAIL);
assertThat(result).isInstanceOf(DiscoveryResult.NotFound.class);
assertThat(fetcher.requestedHosts()).containsExactly("openpgpkey.example.org");
}
@Test
void advanced404FallsBackWhenConfiguredForAnyFailure() {
fetcher.respond("openpgpkey.example.org", 404, null);
fetcher.respond("example.org", 200, TestKeys.encoded(ALICE));
DiscoveryResult result = discovery(true, WkdFallback.ON_ANY_FAILURE).discover(EMAIL);
assertThat(result).isInstanceOfSatisfying(DiscoveryResult.Found.class,
found -> assertThat(found.source()).isEqualTo(DiscoverySource.WKD_DIRECT));
assertThat(fetcher.requestedHosts()).containsExactly("openpgpkey.example.org", "example.org");
}
@Test
void ioErrorIsUnreachable() {
fetcher.fail("openpgpkey.example.org", new SocketTimeoutException("read timed out"));
DiscoveryResult result = discovery(true, WkdFallback.ON_DNS_FAILURE).discover(EMAIL);
assertThat(result).isInstanceOfSatisfying(DiscoveryResult.Unreachable.class,
u -> assertThat(u.reason()).contains("WKD_ADVANCED", "read timed out"));
}
@Test
void serverErrorAndRedirectAreUnreachable() {
fetcher.respond("example.org", 503, null);
assertThat(discovery(false, WkdFallback.ON_DNS_FAILURE).discover(EMAIL))
.isInstanceOfSatisfying(DiscoveryResult.Unreachable.class,
u -> assertThat(u.reason()).contains("HTTP 503"));
fetcher.respond("example.org", 302, null);
assertThat(discovery(false, WkdFallback.ON_DNS_FAILURE).discover(EMAIL))
.isInstanceOf(DiscoveryResult.Unreachable.class);
}
@Test
void malformedBodyIsUnreachable() {
fetcher.respond("example.org", 200, "<html>not a key</html>".getBytes(StandardCharsets.UTF_8));
assertThat(discovery(false, WkdFallback.ON_DNS_FAILURE).discover(EMAIL))
.isInstanceOfSatisfying(DiscoveryResult.Unreachable.class,
u -> assertThat(u.reason()).contains("malformed"));
}
@Test
void emptyBodyIsNotFound() {
fetcher.respond("example.org", 200, new byte[0]);
assertThat(discovery(false, WkdFallback.ON_DNS_FAILURE).discover(EMAIL))
.isInstanceOf(DiscoveryResult.NotFound.class);
}
@Test
void rejectsInvalidAddress() {
assertThatIllegalArgumentException()
.isThrownBy(() -> discovery(true, WkdFallback.ON_DNS_FAILURE).discover("not-an-address"));
}
private WkdKeyDiscovery discovery(boolean advancedHostResolves, WkdFallback fallback) {
HostResolver resolver = host -> !host.startsWith("openpgpkey.") || advancedHostResolves;
return new WkdKeyDiscovery(fetcher, resolver, fallback);
}
private static final class FakeFetcher implements WkdFetcher {
private final Map<String, Response> responses = new HashMap<>();
private final Map<String, IOException> failures = new HashMap<>();
final List<URI> requested = new ArrayList<>();
void respond(String host, int status, byte[] body) {
responses.put(host, new Response(status, body));
}
void fail(String host, IOException e) {
failures.put(host, e);
}
List<String> requestedHosts() {
return requested.stream().map(URI::getHost).toList();
}
@Override
public Response fetch(URI uri) throws IOException {
requested.add(uri);
IOException failure = failures.get(uri.getHost());
if (failure != null) {
throw failure;
}
return responses.getOrDefault(uri.getHost(), new Response(404, null));
}
}
}
@@ -0,0 +1,168 @@
package pro.thinhha.mailsender.pgp.mime;
import static org.assertj.core.api.Assertions.assertThat;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Properties;
import org.bouncycastle.openpgp.api.OpenPGPKey;
import org.junit.jupiter.api.Test;
import org.pgpainless.PGPainless;
import pro.thinhha.mailsender.pgp.crypto.PgpEncryptor;
import pro.thinhha.mailsender.pgp.crypto.PgpEncryptorTest;
import jakarta.activation.DataHandler;
import jakarta.mail.BodyPart;
import jakarta.mail.Message;
import jakarta.mail.Multipart;
import jakarta.mail.Part;
import jakarta.mail.Session;
import jakarta.mail.internet.ContentType;
import jakarta.mail.internet.InternetAddress;
import jakarta.mail.internet.MimeBodyPart;
import jakarta.mail.internet.MimeMessage;
import jakarta.mail.internet.MimeMultipart;
import jakarta.mail.util.ByteArrayDataSource;
class PgpMimeEncryptorTest {
private static final OpenPGPKey ALICE =
PGPainless.getInstance().generateKey().modernKeyRing("Alice <alice@example.org>");
private static final Session SESSION = Session.getInstance(new Properties());
private final PgpMimeEncryptor mimeEncryptor = new PgpMimeEncryptor(new PgpEncryptor());
@Test
void producesRfc3156StructureWithEnvelopeHeadersInTheClear() throws Exception {
MimeMessage wire = sendAndReceive(mimeEncryptor.encrypt(textMessage("Hi Alice\nsecond line"),
List.of(ALICE.toCertificate()), List.of(), () -> new MimeMessage(SESSION)));
ContentType type = new ContentType(wire.getContentType());
assertThat(type.getBaseType()).isEqualTo("multipart/encrypted");
assertThat(type.getParameter("protocol")).isEqualTo("application/pgp-encrypted");
assertThat(wire.getSubject()).isEqualTo("Quarterly numbers");
assertThat(wire.getFrom()).extracting(Object::toString).containsExactly("sender@example.com");
assertThat(wire.getRecipients(Message.RecipientType.TO)).extracting(Object::toString)
.containsExactly("alice@example.org");
assertThat(wire.getRecipients(Message.RecipientType.CC)).extracting(Object::toString)
.containsExactly("bob@example.org");
Multipart parts = (Multipart) wire.getContent();
assertThat(parts.getCount()).isEqualTo(2);
BodyPart control = parts.getBodyPart(0);
assertThat(new ContentType(control.getContentType()).getBaseType()).isEqualTo("application/pgp-encrypted");
assertThat(readAll(control)).isEqualTo("Version: 1\r\n");
BodyPart encrypted = parts.getBodyPart(1);
assertThat(new ContentType(encrypted.getContentType()).getBaseType()).isEqualTo("application/octet-stream");
assertThat(readAll(encrypted)).startsWith("-----BEGIN PGP MESSAGE-----");
assertThat(wire.getContent().toString()).doesNotContain("Hi Alice");
}
@Test
void encryptsWholeInnerEntityIncludingAttachments() throws Exception {
MimeMessage original = textMessage(null);
MimeMultipart mixed = new MimeMultipart("mixed");
MimeBodyPart text = new MimeBodyPart();
text.setText("See attached, Zoë.\nBye", "UTF-8");
MimeBodyPart attachment = new MimeBodyPart();
byte[] pdf = {'%', 'P', 'D', 'F', 0, 1, 2, (byte) 0xFF, '\n'};
attachment.setDataHandler(new DataHandler(new ByteArrayDataSource(pdf, "application/pdf")));
attachment.setFileName("report.pdf");
mixed.addBodyPart(text);
mixed.addBodyPart(attachment);
original.setContent(mixed);
MimeMessage wire = sendAndReceive(mimeEncryptor.encrypt(original, List.of(ALICE.toCertificate()), List.of(),
() -> new MimeMessage(SESSION)));
byte[] armored = readAll(((Multipart) wire.getContent()).getBodyPart(1)).getBytes(StandardCharsets.US_ASCII);
byte[] inner = PgpEncryptorTest.decrypt(armored, ALICE);
assertThat(hasBareLineFeed(inner)).as("inner entity is CRLF-canonical").isFalse();
MimeBodyPart entity = new MimeBodyPart(new ByteArrayInputStream(inner));
assertThat(entity.isMimeType("multipart/mixed")).isTrue();
Multipart decrypted = (Multipart) entity.getContent();
assertThat(decrypted.getCount()).isEqualTo(2);
assertThat(decrypted.getBodyPart(0).getContent().toString()).isEqualTo("See attached, Zoë.\r\nBye");
assertThat(decrypted.getBodyPart(1).getFileName()).isEqualTo("report.pdf");
assertThat(decrypted.getBodyPart(1).getInputStream().readAllBytes()).isEqualTo(pdf);
}
@Test
void leavesOriginalUntouched() throws Exception {
MimeMessage original = textMessage("plain");
mimeEncryptor.encrypt(original, List.of(ALICE.toCertificate()), List.of(), () -> new MimeMessage(SESSION));
assertThat(original.isMimeType("text/plain")).isTrue();
assertThat(original.getContent()).isEqualTo("plain");
}
@Test
void protectedHeadersMoveTheSubjectInside() throws Exception {
PgpMimeEncryptor protecting = new PgpMimeEncryptor(new PgpEncryptor(), true);
MimeMessage original = textMessage("body");
original.setHeader("Message-ID", "<fixed@example.com>");
MimeMessage wire = sendAndReceive(protecting.encrypt(original, List.of(ALICE.toCertificate()), List.of(),
() -> new MimeMessage(SESSION)));
assertThat(wire.getSubject()).isEqualTo(PgpMimeEncryptor.OBSCURED_SUBJECT);
assertThat(original.getMessageID()).as("caller's Message-ID is kept").isEqualTo("<fixed@example.com>");
byte[] armored = readAll(((Multipart) wire.getContent()).getBodyPart(1)).getBytes(StandardCharsets.US_ASCII);
MimeBodyPart inner = new MimeBodyPart(new ByteArrayInputStream(PgpEncryptorTest.decrypt(armored, ALICE)));
assertThat(new ContentType(inner.getContentType()).getParameter("protected-headers")).isEqualTo("v1");
assertThat(inner.getHeader("Subject")).containsExactly("Quarterly numbers");
assertThat(inner.getHeader("To")).containsExactly("alice@example.org");
assertThat(inner.getHeader("Message-ID")).containsExactly("<fixed@example.com>");
assertThat(inner.getContent()).isEqualTo("body");
}
@Test
void headersAreNotProtectedByDefault() throws Exception {
MimeMessage wire = sendAndReceive(mimeEncryptor.encrypt(textMessage("body"), List.of(ALICE.toCertificate()),
List.of(), () -> new MimeMessage(SESSION)));
byte[] armored = readAll(((Multipart) wire.getContent()).getBodyPart(1)).getBytes(StandardCharsets.US_ASCII);
MimeBodyPart inner = new MimeBodyPart(new ByteArrayInputStream(PgpEncryptorTest.decrypt(armored, ALICE)));
assertThat(wire.getSubject()).isEqualTo("Quarterly numbers");
assertThat(inner.getHeader("Subject")).isNull();
assertThat(new ContentType(inner.getContentType()).getParameter("protected-headers")).isNull();
}
private static MimeMessage textMessage(String text) throws Exception {
MimeMessage message = new MimeMessage(SESSION);
message.setFrom(new InternetAddress("sender@example.com"));
message.setRecipient(Message.RecipientType.TO, new InternetAddress("alice@example.org"));
message.setRecipient(Message.RecipientType.CC, new InternetAddress("bob@example.org"));
message.setSubject("Quarterly numbers");
if (text != null) {
message.setText(text, "UTF-8");
}
return message;
}
/** Serializes like SMTP would and parses the result like a receiving client. */
private static MimeMessage sendAndReceive(MimeMessage message) throws Exception {
ByteArrayOutputStream out = new ByteArrayOutputStream();
message.writeTo(out);
return new MimeMessage(SESSION, new ByteArrayInputStream(out.toByteArray()));
}
private static String readAll(Part part) throws Exception {
return new String(part.getInputStream().readAllBytes(), StandardCharsets.US_ASCII);
}
private static boolean hasBareLineFeed(byte[] data) {
for (int i = 0; i < data.length; i++) {
if (data[i] == '\n' && (i == 0 || data[i - 1] != '\r')) {
return true;
}
}
return false;
}
}
@@ -0,0 +1,102 @@
package pro.thinhha.mailsender.pgp.validation;
import static org.assertj.core.api.Assertions.assertThat;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.Date;
import java.util.List;
import org.bouncycastle.bcpg.KeyIdentifier;
import org.bouncycastle.openpgp.api.OpenPGPCertificate;
import org.bouncycastle.openpgp.api.OpenPGPKey;
import org.junit.jupiter.api.Test;
import org.pgpainless.PGPainless;
import org.pgpainless.algorithm.EncryptionPurpose;
import org.pgpainless.key.protection.SecretKeyRingProtector;
class CertificateValidatorTest {
private static final String ALICE = "alice@example.org";
private static final SecretKeyRingProtector NO_PASSPHRASE = SecretKeyRingProtector.unprotectedKeys();
private final PGPainless api = PGPainless.getInstance();
private final CertificateValidator validator = new CertificateValidator();
@Test
void acceptsFreshModernKey() {
assertThat(validator.validate(key("Alice <alice@example.org>").toCertificate(), ALICE))
.isInstanceOf(ValidationResult.Valid.class);
}
@Test
void matchesAddressCaseInsensitivelyAndBareUserIds() {
assertThat(validator.validate(key("Alice <Alice@Example.ORG>").toCertificate(), ALICE))
.isInstanceOf(ValidationResult.Valid.class);
assertThat(validator.validate(key("alice@example.org").toCertificate(), ALICE))
.isInstanceOf(ValidationResult.Valid.class);
}
@Test
void rejectsUserIdForAnotherAddress() {
assertRejected(validator.validate(key("Eve <eve@example.org>").toCertificate(), ALICE),
ValidationResult.Reason.USER_ID_MISMATCH);
// a look-alike must not match
assertRejected(validator.validate(key("Alice <alice@example.org.evil>").toCertificate(), ALICE),
ValidationResult.Reason.USER_ID_MISMATCH);
}
@Test
void rejectsRevokedUserId() throws Exception {
OpenPGPKey key = key("Alice <alice@other.example>");
key = api.modify(key).addUserId("Alice <alice@example.org>", NO_PASSPHRASE).done();
assertThat(validator.validate(key.toCertificate(), ALICE)).isInstanceOf(ValidationResult.Valid.class);
key = api.modify(key).revokeUserId("Alice <alice@example.org>", NO_PASSPHRASE).done();
assertRejected(validator.validate(key.toCertificate(), ALICE), ValidationResult.Reason.USER_ID_MISMATCH);
}
@Test
void rejectsRevokedKey() throws Exception {
OpenPGPKey key = api.modify(key("Alice <alice@example.org>")).revoke(NO_PASSPHRASE).done();
assertRejected(validator.validate(key.toCertificate(), ALICE), ValidationResult.Reason.REVOKED);
}
@Test
void rejectsExpiredKey() {
Instant now = Instant.now();
OpenPGPKey key = api.modify(key("Alice <alice@example.org>"))
.setExpirationDate(Date.from(now.plus(Duration.ofHours(1))), NO_PASSPHRASE)
.done();
CertificateValidator later = new CertificateValidator(api,
Clock.fixed(now.plus(Duration.ofHours(2)), ZoneOffset.UTC));
assertThat(validator.validate(key.toCertificate(), ALICE)).isInstanceOf(ValidationResult.Valid.class);
assertRejected(later.validate(key.toCertificate(), ALICE), ValidationResult.Reason.EXPIRED);
}
@Test
void rejectsKeyWhoseEncryptionSubkeyIsRevoked() throws Exception {
OpenPGPKey key = key("Alice <alice@example.org>");
List<KeyIdentifier> encryptionKeys = api.inspect(key).getEncryptionSubkeys(EncryptionPurpose.COMMUNICATIONS)
.stream().map(OpenPGPCertificate.OpenPGPComponentKey::getKeyIdentifier).toList();
for (KeyIdentifier id : encryptionKeys) {
key = api.modify(key).revokeSubKey(id, NO_PASSPHRASE).done();
}
assertRejected(validator.validate(key.toCertificate(), ALICE), ValidationResult.Reason.NO_ENCRYPTION_SUBKEY);
}
private OpenPGPKey key(String userId) {
return api.generateKey().modernKeyRing(userId);
}
private static void assertRejected(ValidationResult result, ValidationResult.Reason reason) {
assertThat(result).isInstanceOfSatisfying(ValidationResult.Rejected.class,
rejected -> assertThat(rejected.reason()).isEqualTo(reason));
}
}
@@ -0,0 +1,94 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>pro.thinhha.mailsender</groupId>
<artifactId>mailsender-parent</artifactId>
<version>0.1.0-SNAPSHOT</version>
</parent>
<artifactId>mailsender-pgp-spring-boot-autoconfigure</artifactId>
<dependencies>
<dependency>
<groupId>pro.thinhha.mailsender</groupId>
<artifactId>mailsender-pgp-core</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-autoconfigure</artifactId>
</dependency>
<dependency>
<!-- JavaMailSender -->
<groupId>org.springframework</groupId>
<artifactId>spring-context-support</artifactId>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-core</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-actuator</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-actuator-autoconfigure</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.icegreen</groupId>
<artifactId>greenmail-junit5</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<version>${spring-boot.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,12 @@
package pro.thinhha.mailsender.pgp.autoconfigure;
import pro.thinhha.mailsender.pgp.discovery.KeyDiscovery;
/**
* Wraps the WKD discovery before it is cached, so the wrapper sees real lookups only (e.g. metrics, tracing).
*/
@FunctionalInterface
public interface KeyDiscoveryCustomizer {
KeyDiscovery customize(KeyDiscovery wkdDiscovery);
}
@@ -0,0 +1,121 @@
package pro.thinhha.mailsender.pgp.autoconfigure;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.bouncycastle.openpgp.api.OpenPGPCertificate;
import org.springframework.boot.actuate.endpoint.annotation.DeleteOperation;
import org.springframework.boot.actuate.endpoint.annotation.Endpoint;
import org.springframework.boot.actuate.endpoint.annotation.ReadOperation;
import org.springframework.boot.actuate.endpoint.annotation.Selector;
import pro.thinhha.mailsender.pgp.delivery.EncryptionPolicy;
import pro.thinhha.mailsender.pgp.delivery.PolicyResolver;
import pro.thinhha.mailsender.pgp.discovery.CachingKeyDiscovery;
import pro.thinhha.mailsender.pgp.discovery.DiscoveryResult;
import pro.thinhha.mailsender.pgp.discovery.KeyDiscovery;
import pro.thinhha.mailsender.pgp.validation.CertificateValidator;
import pro.thinhha.mailsender.pgp.validation.ValidationResult;
/**
* {@code /actuator/pgpkeys}: inspect how an address would be handled, and evict cached lookups after a recipient
* rotated their key.
* <ul>
* <li>{@code GET pgpkeys} — cache size</li>
* <li>{@code GET pgpkeys/{email}} — policy, key lookup (through the cache) and validation of each key</li>
* <li>{@code DELETE pgpkeys/{email}} / {@code DELETE pgpkeys} — evict one address / everything</li>
* </ul>
*/
@Endpoint(id = "pgpkeys")
public class PgpKeysEndpoint {
private final KeyDiscovery discovery;
private final CachingKeyDiscovery cache;
private final CertificateValidator validator;
private final PolicyResolver policies;
/** @param cache {@code null} when WKD is disabled */
public PgpKeysEndpoint(KeyDiscovery discovery, CachingKeyDiscovery cache, CertificateValidator validator,
PolicyResolver policies) {
this.discovery = discovery;
this.cache = cache;
this.validator = validator;
this.policies = policies;
}
@ReadOperation
public Map<String, Object> summary() {
Map<String, Object> summary = new LinkedHashMap<>();
summary.put("wkd", cache != null);
summary.put("cachedAddresses", cache == null ? 0 : cache.estimatedSize());
return summary;
}
@ReadOperation
public AddressReport address(@Selector String email) {
EncryptionPolicy policy = policies.resolve(email);
DiscoveryResult result = discovery.discover(email);
List<KeyReport> keys = new ArrayList<>();
String lookup;
String detail = null;
switch (result) {
case DiscoveryResult.Found found -> {
lookup = "found:" + found.source();
for (OpenPGPCertificate certificate : found.certificates()) {
keys.add(keyReport(certificate, email));
}
}
case DiscoveryResult.NotFound ignored -> lookup = "not_found";
case DiscoveryResult.Unreachable unreachable -> {
lookup = "unreachable";
detail = unreachable.reason();
}
}
boolean encrypt = policy != EncryptionPolicy.PLAINTEXT && keys.stream().anyMatch(k -> k.valid);
return new AddressReport(email, policy, lookup, detail, encrypt, keys);
}
@DeleteOperation
public void evict(@Selector String email) {
if (cache != null) {
cache.evict(email);
}
}
@DeleteOperation
public void evictAll() {
if (cache != null) {
cache.evictAll();
}
}
private KeyReport keyReport(OpenPGPCertificate certificate, String email) {
ValidationResult validation = validator.validate(certificate, email);
List<String> userIds = certificate.getAllUserIds().stream()
.map(OpenPGPCertificate.OpenPGPUserId::getUserId).toList();
Instant expires = certificate.getExpirationTime() == null ? null : certificate.getExpirationTime().toInstant();
return switch (validation) {
case ValidationResult.Valid ignored ->
new KeyReport(fingerprint(certificate), userIds, expires, true, null, null);
case ValidationResult.Rejected rejected -> new KeyReport(fingerprint(certificate), userIds, expires, false,
rejected.reason().name(), rejected.detail());
};
}
private static String fingerprint(OpenPGPCertificate certificate) {
return HexFormat.of().withUpperCase().formatHex(certificate.getFingerprint());
}
/** @param wouldEncrypt whether a message to this address alone would currently be encrypted */
public record AddressReport(String address, EncryptionPolicy policy, String lookup, String lookupDetail,
boolean wouldEncrypt, List<KeyReport> keys) {
}
public record KeyReport(String fingerprint, List<String> userIds, Instant expires, boolean valid,
String rejectedReason, String rejectedDetail) {
}
}
@@ -0,0 +1,224 @@
package pro.thinhha.mailsender.pgp.autoconfigure;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import org.bouncycastle.openpgp.api.OpenPGPCertificate;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Primary;
import org.springframework.core.io.Resource;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.JavaMailSenderImpl;
import org.springframework.util.StringUtils;
import pro.thinhha.mailsender.pgp.crypto.PgpEncryptor;
import pro.thinhha.mailsender.pgp.delivery.DeliveryPlanner;
import pro.thinhha.mailsender.pgp.delivery.MessageAssembler;
import pro.thinhha.mailsender.pgp.delivery.PolicyResolver;
import pro.thinhha.mailsender.pgp.delivery.RecipientKeyResolver;
import pro.thinhha.mailsender.pgp.delivery.SenderKeyProvider;
import pro.thinhha.mailsender.pgp.delivery.StaticSenderKeyProvider;
import pro.thinhha.mailsender.pgp.discovery.CachingKeyDiscovery;
import pro.thinhha.mailsender.pgp.discovery.Certificates;
import pro.thinhha.mailsender.pgp.discovery.CompositeKeyDiscovery;
import pro.thinhha.mailsender.pgp.discovery.HostResolver;
import pro.thinhha.mailsender.pgp.discovery.HttpWkdFetcher;
import pro.thinhha.mailsender.pgp.discovery.KeyDiscovery;
import pro.thinhha.mailsender.pgp.discovery.StaticKeyDiscovery;
import pro.thinhha.mailsender.pgp.discovery.WkdFetcher;
import pro.thinhha.mailsender.pgp.discovery.WkdKeyDiscovery;
import pro.thinhha.mailsender.pgp.mime.PgpMimeEncryptor;
import pro.thinhha.mailsender.pgp.sender.BuiltInSecureMailSender;
import pro.thinhha.mailsender.pgp.sender.RelaySecureMailSender;
import pro.thinhha.mailsender.pgp.sender.SecureMailSender;
import pro.thinhha.mailsender.pgp.validation.CertificateValidator;
/**
* Wires {@link SecureMailSender} and its parts: key discovery (pinned keys first, then WKD behind a cache),
* validation, policy, delivery planning and PGP/MIME encryption. Every bean backs off if the application defines
* its own. The primary {@link KeyDiscovery} bean is the combined chain; {@link CachingKeyDiscovery} is exposed
* separately so applications can evict entries.
*/
@AutoConfiguration(afterName = "org.springframework.boot.mail.autoconfigure.MailSenderAutoConfiguration")
@EnableConfigurationProperties(PgpMailProperties.class)
@ConditionalOnProperty(prefix = "mail.pgp", name = "enabled", havingValue = "true", matchIfMissing = true)
public class PgpMailAutoConfiguration {
private static final Logger log = LoggerFactory.getLogger(PgpMailAutoConfiguration.class);
@Bean
@ConditionalOnMissingBean
StaticKeyDiscovery pgpStaticKeyDiscovery(PgpMailProperties properties) {
return new StaticKeyDiscovery(readAll("mail.pgp.static-keys", properties.staticKeys()));
}
@Bean
@ConditionalOnMissingBean
@ConditionalOnProperty(prefix = "mail.pgp.wkd", name = "enabled", havingValue = "true", matchIfMissing = true)
WkdFetcher pgpWkdFetcher(PgpMailProperties properties) {
PgpMailProperties.Wkd wkd = properties.wkd();
return new HttpWkdFetcher(wkd.connectTimeout(), wkd.readTimeout(), wkd.maxResponseSize().toBytes());
}
@Bean
@ConditionalOnMissingBean
@ConditionalOnProperty(prefix = "mail.pgp.wkd", name = "enabled", havingValue = "true", matchIfMissing = true)
CachingKeyDiscovery pgpCachingWkdDiscovery(PgpMailProperties properties, WkdFetcher fetcher,
ObjectProvider<HostResolver> hostResolver, ObjectProvider<KeyDiscoveryCustomizer> customizers) {
KeyDiscovery wkd = new WkdKeyDiscovery(fetcher, hostResolver.getIfAvailable(HostResolver::system),
properties.wkd().fallback());
for (KeyDiscoveryCustomizer customizer : customizers.orderedStream().toList()) {
wkd = customizer.customize(wkd);
}
PgpMailProperties.Cache cache = properties.cache();
return new CachingKeyDiscovery(wkd, new CachingKeyDiscovery.Settings(
cache.ttl(), cache.negativeTtl(), cache.errorTtl(), cache.maxSize()));
}
@Bean
@Primary
@ConditionalOnMissingBean(name = "pgpKeyDiscovery")
KeyDiscovery pgpKeyDiscovery(StaticKeyDiscovery staticKeys, ObjectProvider<CachingKeyDiscovery> wkd) {
List<KeyDiscovery> chain = new ArrayList<>();
chain.add(staticKeys);
wkd.ifAvailable(chain::add);
return new CompositeKeyDiscovery(chain);
}
@Bean
@ConditionalOnMissingBean
CertificateValidator pgpCertificateValidator() {
return new CertificateValidator();
}
@Bean
@ConditionalOnMissingBean
PgpEncryptor pgpEncryptor() {
return new PgpEncryptor();
}
@Bean
@ConditionalOnMissingBean
PgpMimeEncryptor pgpMimeEncryptor(PgpMailProperties properties, PgpEncryptor encryptor) {
return new PgpMimeEncryptor(encryptor, properties.protectedHeaders());
}
@Bean
@ConditionalOnMissingBean
PolicyResolver pgpPolicyResolver(PgpMailProperties properties) {
return new PolicyResolver(properties.defaultPolicy(), properties.recipients());
}
@Bean
@ConditionalOnMissingBean
RecipientKeyResolver pgpRecipientKeyResolver(KeyDiscovery discovery, CertificateValidator validator) {
return new RecipientKeyResolver(discovery, validator);
}
@Bean
@ConditionalOnMissingBean
DeliveryPlanner pgpDeliveryPlanner(PgpMailProperties properties, PolicyResolver policies,
RecipientKeyResolver keys) {
return new DeliveryPlanner(policies, keys, properties.mixedMode());
}
@Bean
@ConditionalOnMissingBean
SenderKeyProvider pgpSenderKeyProvider(PgpMailProperties properties, CertificateValidator validator) {
Map<String, OpenPGPCertificate> keys = new LinkedHashMap<>();
readAll("mail.pgp.sender-keys", properties.senderKeys()).forEach((address, certificates) -> {
if (certificates.size() != 1) {
throw new IllegalStateException(
"mail.pgp.sender-keys[" + address + "] must contain exactly one certificate");
}
keys.put(address, certificates.getFirst());
});
if (keys.isEmpty() && properties.mode() == PgpMailProperties.Mode.BUILT_IN) {
log.info("No mail.pgp.sender-keys configured: encrypted mail will not be readable by its sender");
}
return new StaticSenderKeyProvider(keys, validator);
}
@Bean
@ConditionalOnMissingBean
MessageAssembler pgpMessageAssembler(PgpMimeEncryptor mimeEncryptor, SenderKeyProvider senderKeys) {
return new MessageAssembler(mimeEncryptor, senderKeys);
}
@Bean
@ConditionalOnMissingBean
SecureMailSender pgpSecureMailSender(PgpMailProperties properties, ObjectProvider<JavaMailSender> mailSender,
ObjectProvider<DeliveryPlanner> planner, ObjectProvider<MessageAssembler> assembler,
PolicyResolver policies, ObjectProvider<SecureMailSenderCustomizer> customizers) {
SecureMailSender sender = switch (properties.mode()) {
case BUILT_IN -> {
JavaMailSender delegate = mailSender.getIfAvailable();
if (delegate == null) {
throw new IllegalStateException(
"mail.pgp.mode=BUILT_IN needs a JavaMailSender bean; set spring.mail.host");
}
yield new BuiltInSecureMailSender(delegate, planner.getObject(), assembler.getObject());
}
case RELAY -> {
PgpMailProperties.Relay relay = properties.relay();
log.info("mail.pgp.mode=RELAY: key discovery, encryption and policy enforcement are delegated to "
+ "the gateway at {}:{}", relay.host(), relay.port());
yield new RelaySecureMailSender(relaySender(relay), policies, relay.policyHeader());
}
};
for (SecureMailSenderCustomizer customizer : customizers.orderedStream().toList()) {
sender = customizer.customize(sender);
}
return sender;
}
/** Not a bean: a second JavaMailSender would make Boot's own {@code spring.mail} sender back off. */
private static JavaMailSenderImpl relaySender(PgpMailProperties.Relay relay) {
if (!StringUtils.hasText(relay.host())) {
throw new IllegalStateException("mail.pgp.mode=RELAY requires mail.pgp.relay.host");
}
JavaMailSenderImpl sender = new JavaMailSenderImpl();
sender.setHost(relay.host());
sender.setPort(relay.port());
sender.setUsername(relay.username());
sender.setPassword(relay.password());
Properties javaMail = new Properties();
javaMail.setProperty("mail.smtp.auth", String.valueOf(StringUtils.hasText(relay.username())));
javaMail.setProperty("mail.smtp.starttls.enable", String.valueOf(relay.starttls()));
javaMail.setProperty("mail.smtp.starttls.required", String.valueOf(relay.starttls()));
javaMail.putAll(relay.properties());
sender.setJavaMailProperties(javaMail);
return sender;
}
private static Map<String, List<OpenPGPCertificate>> readAll(String property, Map<String, Resource> resources) {
Map<String, List<OpenPGPCertificate>> certificates = new LinkedHashMap<>();
resources.forEach((email, resource) -> certificates.put(email, read(property, email, resource)));
return certificates;
}
private static List<OpenPGPCertificate> read(String property, String email, Resource resource) {
try (InputStream in = resource.getInputStream()) {
List<OpenPGPCertificate> certificates = Certificates.parse(in.readAllBytes());
if (certificates.isEmpty()) {
throw new IllegalStateException(property + "[" + email + "]: no certificate in " + resource);
}
return certificates;
} catch (IOException e) {
throw new UncheckedIOException(property + "[" + email + "]: cannot read " + resource, e);
}
}
}
@@ -0,0 +1,29 @@
package pro.thinhha.mailsender.pgp.autoconfigure;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.actuate.autoconfigure.endpoint.condition.ConditionalOnAvailableEndpoint;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import pro.thinhha.mailsender.pgp.delivery.PolicyResolver;
import pro.thinhha.mailsender.pgp.discovery.CachingKeyDiscovery;
import pro.thinhha.mailsender.pgp.discovery.KeyDiscovery;
import pro.thinhha.mailsender.pgp.validation.CertificateValidator;
/** Registers {@link PgpKeysEndpoint} when Actuator is present and the endpoint is enabled and exposed. */
@AutoConfiguration(after = PgpMailAutoConfiguration.class)
@ConditionalOnClass(name = "org.springframework.boot.actuate.endpoint.annotation.Endpoint")
@ConditionalOnBean(PolicyResolver.class)
public class PgpMailEndpointAutoConfiguration {
@Bean
@ConditionalOnMissingBean
@ConditionalOnAvailableEndpoint
PgpKeysEndpoint pgpKeysEndpoint(KeyDiscovery discovery, ObjectProvider<CachingKeyDiscovery> cache,
CertificateValidator validator, PolicyResolver policies) {
return new PgpKeysEndpoint(discovery, cache.getIfAvailable(), validator, policies);
}
}
@@ -0,0 +1,53 @@
package pro.thinhha.mailsender.pgp.autoconfigure;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import io.micrometer.core.instrument.Gauge;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.binder.MeterBinder;
import pro.thinhha.mailsender.pgp.discovery.CachingKeyDiscovery;
import pro.thinhha.mailsender.pgp.metrics.MeteredKeyDiscovery;
import pro.thinhha.mailsender.pgp.metrics.MeteredSecureMailSender;
/**
* Micrometer metrics when a {@link MeterRegistry} bean exists:
* <ul>
* <li>{@code mail.pgp.recipients{outcome, policy, reason}} — counter per recipient</li>
* <li>{@code mail.pgp.refused} — messages refused by the policy</li>
* <li>{@code mail.pgp.key.lookup{result, source}} — timer for lookups not answered from the cache</li>
* <li>{@code mail.pgp.key.cache.size} — cached addresses</li>
* </ul>
*/
@AutoConfiguration
@ConditionalOnClass(MeterRegistry.class)
@ConditionalOnProperty(prefix = "mail.pgp", name = "enabled", havingValue = "true", matchIfMissing = true)
public class PgpMailMetricsAutoConfiguration {
@Bean
KeyDiscoveryCustomizer pgpKeyDiscoveryMetrics(ObjectProvider<MeterRegistry> registry) {
return discovery -> {
MeterRegistry meterRegistry = registry.getIfAvailable();
return meterRegistry == null ? discovery : new MeteredKeyDiscovery(discovery, meterRegistry);
};
}
@Bean
SecureMailSenderCustomizer pgpSecureMailSenderMetrics(ObjectProvider<MeterRegistry> registry) {
return sender -> {
MeterRegistry meterRegistry = registry.getIfAvailable();
return meterRegistry == null ? sender : new MeteredSecureMailSender(sender, meterRegistry);
};
}
@Bean
MeterBinder pgpKeyCacheMetrics(ObjectProvider<CachingKeyDiscovery> cache) {
return registry -> cache.ifAvailable(c -> Gauge.builder("mail.pgp.key.cache.size", c,
CachingKeyDiscovery::estimatedSize)
.description("Addresses with a cached key lookup result")
.register(registry));
}
}
@@ -0,0 +1,98 @@
package pro.thinhha.mailsender.pgp.autoconfigure;
import java.time.Duration;
import java.util.Map;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;
import org.springframework.core.io.Resource;
import org.springframework.util.unit.DataSize;
import pro.thinhha.mailsender.pgp.delivery.EncryptionPolicy;
import pro.thinhha.mailsender.pgp.delivery.MixedMode;
import pro.thinhha.mailsender.pgp.discovery.WkdFallback;
/**
* {@code mail.pgp.*} configuration.
* <p>
* Map keys that are e-mail addresses must use bracket notation so Spring keeps the {@code @} and {@code .}
* characters, e.g. {@code mail.pgp.static-keys[bob@legacy.org]=file:/etc/keys/bob.asc}.
*
* @param enabled master switch
* @param mode encrypt in this application or hand mail to an encrypting relay
* @param defaultPolicy policy for recipients without a rule
* @param mixedMode what to do when only some recipients can get encrypted mail
* @param protectedHeaders move the real Subject (and other headers) into the encrypted part (RFC 9788); the
* visible Subject becomes {@code [...]}
* @param recipients policy rules per address or {@code *@domain}
* @param staticKeys pinned recipient certificates per address, checked before WKD
* @param senderKeys the sender's own public key per From address, so sent copies stay readable
*/
@ConfigurationProperties("mail.pgp")
public record PgpMailProperties(
@DefaultValue("true") boolean enabled,
@DefaultValue("BUILT_IN") Mode mode,
@DefaultValue("OPPORTUNISTIC") EncryptionPolicy defaultPolicy,
@DefaultValue("SPLIT") MixedMode mixedMode,
@DefaultValue("false") boolean protectedHeaders,
Map<String, EncryptionPolicy> recipients,
@DefaultValue Wkd wkd,
@DefaultValue Cache cache,
Map<String, Resource> staticKeys,
Map<String, Resource> senderKeys,
@DefaultValue Relay relay) {
public PgpMailProperties {
recipients = recipients == null ? Map.of() : Map.copyOf(recipients);
staticKeys = staticKeys == null ? Map.of() : Map.copyOf(staticKeys);
senderKeys = senderKeys == null ? Map.of() : Map.copyOf(senderKeys);
}
public enum Mode {
/** Discover keys and encrypt in this application, send through the application's {@code JavaMailSender}. */
BUILT_IN,
/** Send plaintext to an encrypting gateway configured under {@code mail.pgp.relay}. */
RELAY
}
public record Wkd(
@DefaultValue("true") boolean enabled,
@DefaultValue("ON_DNS_FAILURE") WkdFallback fallback,
@DefaultValue("3s") Duration connectTimeout,
@DefaultValue("5s") Duration readTimeout,
@DefaultValue("256KB") DataSize maxResponseSize) {
}
/**
* @param ttl lifetime of a found key (capped at the key's expiry)
* @param negativeTtl lifetime of "no key published"
* @param errorTtl lifetime of a failed lookup (timeout, 5xx, malformed response)
*/
public record Cache(
@DefaultValue("24h") Duration ttl,
@DefaultValue("1h") Duration negativeTtl,
@DefaultValue("5m") Duration errorTtl,
@DefaultValue("10000") long maxSize) {
}
/**
* SMTP connection to the encrypting gateway ({@code mode=RELAY}). The hop carries plaintext, so STARTTLS is
* required by default.
*
* @param policyHeader header that carries the strictest recipient policy as a hint for the gateway; empty for none
* @param properties extra JavaMail properties, e.g. {@code mail.smtp.ssl.trust}
*/
public record Relay(
String host,
@DefaultValue("587") int port,
String username,
String password,
@DefaultValue("true") boolean starttls,
String policyHeader,
Map<String, String> properties) {
public Relay {
properties = properties == null ? Map.of() : Map.copyOf(properties);
}
}
}
@@ -0,0 +1,10 @@
package pro.thinhha.mailsender.pgp.autoconfigure;
import pro.thinhha.mailsender.pgp.sender.SecureMailSender;
/** Wraps the auto-configured {@link SecureMailSender} (e.g. metrics, auditing). */
@FunctionalInterface
public interface SecureMailSenderCustomizer {
SecureMailSender customize(SecureMailSender sender);
}
@@ -0,0 +1,41 @@
package pro.thinhha.mailsender.pgp.metrics;
import java.util.Locale;
import java.util.Objects;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import pro.thinhha.mailsender.pgp.discovery.DiscoveryResult;
import pro.thinhha.mailsender.pgp.discovery.KeyDiscovery;
/** Times each real key lookup: {@code mail.pgp.key.lookup{result, source}}. */
public final class MeteredKeyDiscovery implements KeyDiscovery {
static final String LOOKUP = "mail.pgp.key.lookup";
private final KeyDiscovery delegate;
private final MeterRegistry registry;
public MeteredKeyDiscovery(KeyDiscovery delegate, MeterRegistry registry) {
this.delegate = Objects.requireNonNull(delegate, "delegate");
this.registry = Objects.requireNonNull(registry, "registry");
}
@Override
public DiscoveryResult discover(String email) {
Timer.Sample sample = Timer.start(registry);
DiscoveryResult result = delegate.discover(email);
sample.stop(Timer.builder(LOOKUP)
.description("Key lookups that were not answered from the cache")
.tag("result", switch (result) {
case DiscoveryResult.Found ignored -> "found";
case DiscoveryResult.NotFound ignored -> "not_found";
case DiscoveryResult.Unreachable ignored -> "unreachable";
})
.tag("source", result instanceof DiscoveryResult.Found found
? found.source().name().toLowerCase(Locale.ROOT)
: "none")
.register(registry));
return result;
}
}
@@ -0,0 +1,78 @@
package pro.thinhha.mailsender.pgp.metrics;
import java.util.Locale;
import java.util.Objects;
import org.springframework.mail.SimpleMailMessage;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import jakarta.mail.internet.MimeMessage;
import pro.thinhha.mailsender.pgp.delivery.EncryptionPolicyException;
import pro.thinhha.mailsender.pgp.sender.SecureMailSender;
import pro.thinhha.mailsender.pgp.sender.SendReport;
/**
* Counts per recipient {@code mail.pgp.recipients{outcome, policy, reason}} and refused messages
* {@code mail.pgp.refused}. A plaintext recipient with policy {@code opportunistic} is a downgrade.
*/
public final class MeteredSecureMailSender implements SecureMailSender {
static final String RECIPIENTS = "mail.pgp.recipients";
static final String REFUSED = "mail.pgp.refused";
private final SecureMailSender delegate;
private final MeterRegistry registry;
public MeteredSecureMailSender(SecureMailSender delegate, MeterRegistry registry) {
this.delegate = Objects.requireNonNull(delegate, "delegate");
this.registry = Objects.requireNonNull(registry, "registry");
}
@Override
public MimeMessage createMimeMessage() {
return delegate.createMimeMessage();
}
@Override
public SendReport send(MimeMessage message) {
try {
return record(delegate.send(message));
} catch (EncryptionPolicyException e) {
refused();
throw e;
}
}
@Override
public SendReport send(SimpleMailMessage message) {
try {
return record(delegate.send(message));
} catch (EncryptionPolicyException e) {
refused();
throw e;
}
}
private SendReport record(SendReport report) {
for (SendReport.RecipientOutcome recipient : report.recipients()) {
Counter.builder(RECIPIENTS)
.description("Recipients by delivery outcome")
.tag("outcome", lower(recipient.outcome()))
.tag("policy", lower(recipient.policy()))
.tag("reason", lower(recipient.reason()))
.register(registry)
.increment();
}
return report;
}
private void refused() {
Counter.builder(REFUSED).description("Messages refused by the encryption policy").register(registry)
.increment();
}
private static String lower(Enum<?> value) {
return value.name().toLowerCase(Locale.ROOT);
}
}
@@ -0,0 +1,41 @@
package pro.thinhha.mailsender.pgp.sender;
import java.util.ArrayList;
import java.util.List;
import org.springframework.mail.MailParseException;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.MimeMailMessage;
import jakarta.mail.Address;
import jakarta.mail.Message;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.InternetAddress;
import jakarta.mail.internet.MimeMessage;
abstract class AbstractSecureMailSender implements SecureMailSender {
@Override
public SendReport send(SimpleMailMessage simpleMessage) {
MimeMailMessage message = new MimeMailMessage(createMimeMessage());
simpleMessage.copyTo(message);
return send(message.getMimeMessage());
}
static List<InternetAddress> recipients(MimeMessage message, Message.RecipientType type) {
try {
Address[] addresses = message.getRecipients(type);
List<InternetAddress> result = new ArrayList<>();
if (addresses != null) {
for (Address address : addresses) {
if (address instanceof InternetAddress internet) {
result.add(internet);
}
}
}
return result;
} catch (MessagingException e) {
throw new MailParseException("Cannot read " + type + " recipients", e);
}
}
}
@@ -0,0 +1,89 @@
package pro.thinhha.mailsender.pgp.sender;
import java.io.IOException;
import java.util.List;
import java.util.Objects;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.mail.MailPreparationException;
import org.springframework.mail.javamail.JavaMailSender;
import jakarta.mail.Message;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import pro.thinhha.mailsender.pgp.delivery.DeliveryPlan;
import pro.thinhha.mailsender.pgp.delivery.DeliveryPlanner;
import pro.thinhha.mailsender.pgp.delivery.MessageAssembler;
import pro.thinhha.mailsender.pgp.delivery.RecipientDecision;
import pro.thinhha.mailsender.pgp.delivery.RecipientKey;
/** Encrypts in this application: plan per recipient, build the copies, send them over one SMTP connection. */
public final class BuiltInSecureMailSender extends AbstractSecureMailSender {
private static final Logger log = LoggerFactory.getLogger(BuiltInSecureMailSender.class);
private final JavaMailSender delegate;
private final DeliveryPlanner planner;
private final MessageAssembler assembler;
public BuiltInSecureMailSender(JavaMailSender delegate, DeliveryPlanner planner, MessageAssembler assembler) {
this.delegate = Objects.requireNonNull(delegate, "delegate");
this.planner = Objects.requireNonNull(planner, "planner");
this.assembler = Objects.requireNonNull(assembler, "assembler");
}
@Override
public MimeMessage createMimeMessage() {
return delegate.createMimeMessage();
}
@Override
public SendReport send(MimeMessage message) {
DeliveryPlan plan = planner.plan(
recipients(message, Message.RecipientType.TO),
recipients(message, Message.RecipientType.CC),
recipients(message, Message.RecipientType.BCC));
List<MimeMessage> copies;
try {
copies = assembler.assemble(message, plan, delegate::createMimeMessage);
} catch (MessagingException | IOException e) {
throw new MailPreparationException("Could not build encrypted message", e);
}
plan.decisions().stream().filter(RecipientDecision::isDowngrade).forEach(d ->
log.warn("Sending to {} in PLAINTEXT (policy {}): {}", d.address(), d.policy(), describe(d.key())));
delegate.send(copies.toArray(MimeMessage[]::new));
SendReport report = new SendReport(plan.decisions().stream()
.map(d -> new SendReport.RecipientOutcome(d.address(), d.type(), d.policy(),
d.encrypted() ? SendReport.Outcome.ENCRYPTED : SendReport.Outcome.PLAINTEXT,
d.encrypted() ? SendReport.PlaintextReason.NONE : reason(d.key()),
d.encrypted() ? null : describe(d.key())))
.toList());
log.debug("Sent {} cop{}: {}", copies.size(), copies.size() == 1 ? "y" : "ies", report);
return report;
}
private static SendReport.PlaintextReason reason(RecipientKey key) {
return switch (key) {
case RecipientKey.Usable ignored -> SendReport.PlaintextReason.NONE;
case RecipientKey.Missing missing -> switch (missing.reason()) {
case NOT_FOUND -> SendReport.PlaintextReason.NOT_FOUND;
case UNREACHABLE -> SendReport.PlaintextReason.UNREACHABLE;
case REJECTED -> SendReport.PlaintextReason.REJECTED;
};
case RecipientKey.NotLookedUp ignored -> SendReport.PlaintextReason.PLAINTEXT_POLICY;
};
}
private static String describe(RecipientKey key) {
return switch (key) {
case RecipientKey.Usable usable -> "key from " + usable.source();
case RecipientKey.Missing missing ->
missing.detail() == null ? missing.reason().toString() : missing.reason() + ": " + missing.detail();
case RecipientKey.NotLookedUp ignored -> "plaintext policy";
};
}
}
@@ -0,0 +1,72 @@
package pro.thinhha.mailsender.pgp.sender;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import org.springframework.mail.MailPreparationException;
import org.springframework.mail.javamail.JavaMailSender;
import jakarta.mail.Message;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.InternetAddress;
import jakarta.mail.internet.MimeMessage;
import pro.thinhha.mailsender.pgp.delivery.EncryptionPolicy;
import pro.thinhha.mailsender.pgp.delivery.PolicyResolver;
import pro.thinhha.mailsender.pgp.delivery.RecipientType;
/**
* Hands the plaintext message to an encrypting gateway (for example a GnuPG-based filter using
* {@code --auto-key-locate wkd}). Key discovery, encryption and policy enforcement happen there; this sender can
* only pass the strictest recipient policy along as a header hint.
*/
public final class RelaySecureMailSender extends AbstractSecureMailSender {
private final JavaMailSender relay;
private final PolicyResolver policies;
private final String policyHeader;
/** @param policyHeader header carrying the policy hint, or {@code null} for none */
public RelaySecureMailSender(JavaMailSender relay, PolicyResolver policies, String policyHeader) {
this.relay = Objects.requireNonNull(relay, "relay");
this.policies = Objects.requireNonNull(policies, "policies");
this.policyHeader = policyHeader == null || policyHeader.isBlank() ? null : policyHeader;
}
@Override
public MimeMessage createMimeMessage() {
return relay.createMimeMessage();
}
@Override
public SendReport send(MimeMessage message) {
List<SendReport.RecipientOutcome> outcomes = new ArrayList<>();
addOutcomes(outcomes, recipients(message, Message.RecipientType.TO), RecipientType.TO);
addOutcomes(outcomes, recipients(message, Message.RecipientType.CC), RecipientType.CC);
addOutcomes(outcomes, recipients(message, Message.RecipientType.BCC), RecipientType.BCC);
if (policyHeader != null && !outcomes.isEmpty()) {
// Enum order is strictest first: REQUIRE, OPPORTUNISTIC, PLAINTEXT.
EncryptionPolicy strictest = outcomes.stream().map(SendReport.RecipientOutcome::policy)
.min(Comparator.naturalOrder()).orElseThrow();
try {
message.setHeader(policyHeader, strictest.name().toLowerCase(Locale.ROOT));
} catch (MessagingException e) {
throw new MailPreparationException("Could not set " + policyHeader, e);
}
}
relay.send(message);
return new SendReport(outcomes);
}
private void addOutcomes(List<SendReport.RecipientOutcome> outcomes, List<InternetAddress> addresses,
RecipientType type) {
for (InternetAddress address : addresses) {
outcomes.add(new SendReport.RecipientOutcome(address.getAddress(), type,
policies.resolve(address.getAddress()), SendReport.Outcome.RELAYED, SendReport.PlaintextReason.NONE,
null));
}
}
}
@@ -0,0 +1,25 @@
package pro.thinhha.mailsender.pgp.sender;
import org.springframework.mail.MailException;
import org.springframework.mail.SimpleMailMessage;
import jakarta.mail.internet.MimeMessage;
import pro.thinhha.mailsender.pgp.delivery.EncryptionPolicyException;
/**
* Sends mail with OpenPGP encryption according to the configured policy. Build the message as you would for
* {@code JavaMailSender} (plaintext); the sender decides per recipient whether it goes out encrypted.
*/
public interface SecureMailSender {
MimeMessage createMimeMessage();
/**
* @throws EncryptionPolicyException if the policy forbids sending (nothing is sent)
* @throws MailException on preparation or SMTP failures
*/
SendReport send(MimeMessage message);
/** @see #send(MimeMessage) */
SendReport send(SimpleMailMessage message);
}
@@ -0,0 +1,48 @@
package pro.thinhha.mailsender.pgp.sender;
import java.util.List;
import pro.thinhha.mailsender.pgp.delivery.EncryptionPolicy;
import pro.thinhha.mailsender.pgp.delivery.RecipientType;
/** What happened to each recipient of a sent message. */
public record SendReport(List<RecipientOutcome> recipients) {
public SendReport {
recipients = List.copyOf(recipients);
}
public boolean allEncrypted() {
return recipients.stream().allMatch(r -> r.outcome() == Outcome.ENCRYPTED);
}
/**
* @param reason why the recipient got plaintext; {@link PlaintextReason#NONE} otherwise
* @param detail human-readable detail for the reason, or {@code null}
*/
public record RecipientOutcome(String address, RecipientType type, EncryptionPolicy policy, Outcome outcome,
PlaintextReason reason, String detail) {
}
public enum PlaintextReason {
/** Not sent in plaintext by this application (encrypted, or relayed). */
NONE,
/** The recipient's policy is PLAINTEXT. */
PLAINTEXT_POLICY,
/** No key published. */
NOT_FOUND,
/** The key lookup failed. */
UNREACHABLE,
/** Keys were found but none was usable. */
REJECTED
}
public enum Outcome {
/** Sent PGP/MIME-encrypted by this application. */
ENCRYPTED,
/** Sent in plaintext by this application. */
PLAINTEXT,
/** Handed to the encrypting relay, which decides. */
RELAYED
}
}
@@ -0,0 +1,3 @@
pro.thinhha.mailsender.pgp.autoconfigure.PgpMailAutoConfiguration
pro.thinhha.mailsender.pgp.autoconfigure.PgpMailMetricsAutoConfiguration
pro.thinhha.mailsender.pgp.autoconfigure.PgpMailEndpointAutoConfiguration
@@ -0,0 +1,56 @@
package pro.thinhha.mailsender.pgp.autoconfigure;
import static org.assertj.core.api.Assertions.assertThat;
import java.nio.file.Files;
import java.nio.file.Path;
import org.junit.jupiter.api.Test;
import org.pgpainless.PGPainless;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.http.HttpStatus;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.web.client.RestClient;
/** Addresses contain '@' and '.', so check the selector survives real HTTP path matching. */
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = {
"spring.mail.host=localhost",
"mail.pgp.wkd.enabled=false",
"management.endpoints.web.exposure.include=pgpkeys"
})
class PgpKeysEndpointWebTest {
@SpringBootConfiguration
@EnableAutoConfiguration
static class App {
}
@DynamicPropertySource
static void keys(DynamicPropertyRegistry registry) throws Exception {
Path key = Files.createTempFile("bob", ".asc");
key.toFile().deleteOnExit();
Files.writeString(key, PGPainless.getInstance().generateKey()
.modernKeyRing("Bob <bob@legacy.org>").toCertificate().toAsciiArmoredString());
registry.add("mail.pgp.static-keys[bob@legacy.org]", () -> "file:" + key);
}
@LocalServerPort
int port;
@Test
void readsAndEvictsByAddress() {
RestClient client = RestClient.create("http://localhost:" + port + "/actuator/pgpkeys");
String body = client.get().uri("/bob@legacy.org").retrieve().body(String.class);
assertThat(body).contains("\"address\":\"bob@legacy.org\"", "\"lookup\":\"found:STATIC\"",
"\"wouldEncrypt\":true");
assertThat(client.delete().uri("/bob@legacy.org").retrieve().toBodilessEntity().getStatusCode())
.isEqualTo(HttpStatus.NO_CONTENT);
assertThat(client.get().retrieve().body(String.class)).contains("\"cachedAddresses\"");
}
}
@@ -0,0 +1,126 @@
package pro.thinhha.mailsender.pgp.autoconfigure;
import static org.assertj.core.api.Assertions.assertThat;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.pgpainless.PGPainless;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.mail.autoconfigure.MailSenderAutoConfiguration;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.mail.javamail.JavaMailSender;
import pro.thinhha.mailsender.pgp.discovery.CachingKeyDiscovery;
import pro.thinhha.mailsender.pgp.discovery.DiscoveryResult;
import pro.thinhha.mailsender.pgp.discovery.DiscoverySource;
import pro.thinhha.mailsender.pgp.discovery.KeyDiscovery;
import pro.thinhha.mailsender.pgp.discovery.WkdFallback;
import pro.thinhha.mailsender.pgp.discovery.WkdFetcher;
import pro.thinhha.mailsender.pgp.mime.PgpMimeEncryptor;
import pro.thinhha.mailsender.pgp.sender.BuiltInSecureMailSender;
import pro.thinhha.mailsender.pgp.sender.RelaySecureMailSender;
import pro.thinhha.mailsender.pgp.sender.SecureMailSender;
import pro.thinhha.mailsender.pgp.validation.CertificateValidator;
class PgpMailAutoConfigurationTest {
private final ApplicationContextRunner runner = new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(MailSenderAutoConfiguration.class, PgpMailAutoConfiguration.class))
.withPropertyValues("spring.mail.host=localhost");
@TempDir
Path tempDir;
@Test
void createsDiscoveryChainWithDefaults() {
runner.run(context -> {
assertThat(context).hasSingleBean(WkdFetcher.class).hasSingleBean(CachingKeyDiscovery.class)
.hasSingleBean(CertificateValidator.class).hasSingleBean(PgpMimeEncryptor.class);
assertThat(context.getBean(KeyDiscovery.class)).isNotInstanceOf(CachingKeyDiscovery.class);
PgpMailProperties properties = context.getBean(PgpMailProperties.class);
assertThat(properties.wkd().fallback()).isEqualTo(WkdFallback.ON_DNS_FAILURE);
assertThat(properties.cache().ttl()).isEqualTo(Duration.ofHours(24));
assertThat(properties.cache().negativeTtl()).isEqualTo(Duration.ofHours(1));
});
}
@Test
void pinnedKeysAreServedWithoutWkd() throws IOException {
Path key = tempDir.resolve("bob.asc");
Files.writeString(key, PGPainless.getInstance().generateKey()
.modernKeyRing("Bob <bob@legacy.org>").toCertificate().toAsciiArmoredString());
runner.withPropertyValues(
"mail.pgp.wkd.enabled=false",
"mail.pgp.static-keys[Bob@Legacy.org]=file:" + key)
.run(context -> {
assertThat(context).doesNotHaveBean(WkdFetcher.class).doesNotHaveBean(CachingKeyDiscovery.class);
KeyDiscovery discovery = context.getBean(KeyDiscovery.class);
assertThat(discovery.discover("bob@legacy.org"))
.isInstanceOfSatisfying(DiscoveryResult.Found.class,
found -> assertThat(found.source()).isEqualTo(DiscoverySource.STATIC));
assertThat(discovery.discover("carol@example.org")).isInstanceOf(DiscoveryResult.NotFound.class);
});
}
@Test
void failsFastOnUnreadablePinnedKey() {
runner.withPropertyValues("mail.pgp.static-keys[bob@legacy.org]=file:" + tempDir.resolve("missing.asc"))
.run(context -> assertThat(context).hasFailed());
}
@Test
void builtInModeWrapsTheApplicationsMailSender() {
runner.run(context -> assertThat(context.getBean(SecureMailSender.class))
.isInstanceOf(BuiltInSecureMailSender.class));
}
@Test
void builtInModeWithoutMailSenderFailsWithHint() {
new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(PgpMailAutoConfiguration.class))
.run(context -> assertThat(context).hasFailed().getFailure()
.rootCause().hasMessageContaining("spring.mail.host"));
}
@Test
void relayModeKeepsTheApplicationsMailSender() {
runner.withPropertyValues("mail.pgp.mode=relay", "mail.pgp.relay.host=gateway.internal")
.run(context -> {
assertThat(context.getBean(SecureMailSender.class)).isInstanceOf(RelaySecureMailSender.class);
assertThat(context).hasSingleBean(JavaMailSender.class);
assertThat(context.getBean(JavaMailSender.class)).extracting("host").isEqualTo("localhost");
});
}
@Test
void relayModeRequiresHost() {
runner.withPropertyValues("mail.pgp.mode=RELAY")
.run(context -> assertThat(context).hasFailed().getFailure()
.rootCause().hasMessageContaining("mail.pgp.relay.host"));
}
@Test
void senderKeyMustMatchItsAddress() throws IOException {
Path key = tempDir.resolve("sender.asc");
Files.writeString(key, PGPainless.getInstance().generateKey()
.modernKeyRing("<someone-else@example.com>").toCertificate().toAsciiArmoredString());
runner.withPropertyValues("mail.pgp.sender-keys[noreply@example.com]=file:" + key)
.run(context -> assertThat(context).hasFailed().getFailure()
.rootCause().hasMessageContaining("USER_ID_MISMATCH"));
}
@Test
void disabledCreatesNothing() {
runner.withPropertyValues("mail.pgp.enabled=false")
.run(context -> assertThat(context).doesNotHaveBean(KeyDiscovery.class));
}
}
@@ -0,0 +1,94 @@
package pro.thinhha.mailsender.pgp.autoconfigure;
import static org.assertj.core.api.Assertions.assertThat;
import java.nio.file.Files;
import java.nio.file.Path;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.pgpainless.PGPainless;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.mail.autoconfigure.MailSenderAutoConfiguration;
import org.springframework.boot.test.context.FilteredClassLoader;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import pro.thinhha.mailsender.pgp.delivery.EncryptionPolicy;
import pro.thinhha.mailsender.pgp.metrics.MeteredSecureMailSender;
import pro.thinhha.mailsender.pgp.sender.BuiltInSecureMailSender;
import pro.thinhha.mailsender.pgp.sender.SecureMailSender;
class PgpMailObservabilityAutoConfigurationTest {
private final ApplicationContextRunner runner = new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(MailSenderAutoConfiguration.class,
PgpMailAutoConfiguration.class, PgpMailMetricsAutoConfiguration.class,
PgpMailEndpointAutoConfiguration.class))
.withPropertyValues("spring.mail.host=localhost");
@TempDir
Path tempDir;
@Test
void wrapsSenderWithMetricsWhenRegistryPresent() {
runner.withBean(MeterRegistry.class, SimpleMeterRegistry::new)
.run(context -> assertThat(context.getBean(SecureMailSender.class))
.isInstanceOf(MeteredSecureMailSender.class));
}
@Test
void leavesSenderAloneWithoutRegistry() {
runner.run(context -> assertThat(context.getBean(SecureMailSender.class))
.isInstanceOf(BuiltInSecureMailSender.class));
}
@Test
void worksWithoutMicrometerAndActuatorOnClasspath() {
runner.withClassLoader(new FilteredClassLoader("io.micrometer", "org.springframework.boot.actuate"))
.run(context -> {
assertThat(context).hasNotFailed();
assertThat(context.getBean(SecureMailSender.class)).isInstanceOf(BuiltInSecureMailSender.class);
});
}
@Test
void endpointOnlyWhenExposed() {
runner.run(context -> assertThat(context).doesNotHaveBean(PgpKeysEndpoint.class));
}
@Test
void endpointReportsPolicyKeyAndValidity() throws Exception {
Path key = tempDir.resolve("bob.asc");
Files.writeString(key, PGPainless.getInstance().generateKey()
.modernKeyRing("Bob <bob@legacy.org>").toCertificate().toAsciiArmoredString());
runner.withPropertyValues(
"management.endpoints.web.exposure.include=pgpkeys",
"mail.pgp.wkd.enabled=false",
"mail.pgp.recipients[*@legacy.org]=REQUIRE",
"mail.pgp.static-keys[bob@legacy.org]=file:" + key)
.run(context -> {
PgpKeysEndpoint endpoint = context.getBean(PgpKeysEndpoint.class);
PgpKeysEndpoint.AddressReport bob = endpoint.address("bob@legacy.org");
assertThat(bob.policy()).isEqualTo(EncryptionPolicy.REQUIRE);
assertThat(bob.lookup()).isEqualTo("found:STATIC");
assertThat(bob.wouldEncrypt()).isTrue();
assertThat(bob.keys()).singleElement().satisfies(k -> {
assertThat(k.valid()).isTrue();
assertThat(k.userIds()).containsExactly("Bob <bob@legacy.org>");
assertThat(k.fingerprint()).hasSize(40);
});
PgpKeysEndpoint.AddressReport carol = endpoint.address("carol@example.org");
assertThat(carol.lookup()).isEqualTo("not_found");
assertThat(carol.wouldEncrypt()).isFalse();
assertThat(endpoint.summary()).containsEntry("wkd", false);
endpoint.evict("bob@legacy.org");
endpoint.evictAll();
});
}
}
@@ -0,0 +1,88 @@
package pro.thinhha.mailsender.pgp.metrics;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.mail.SimpleMailMessage;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import jakarta.mail.internet.MimeMessage;
import pro.thinhha.mailsender.pgp.delivery.EncryptionPolicy;
import pro.thinhha.mailsender.pgp.delivery.EncryptionPolicyException;
import pro.thinhha.mailsender.pgp.delivery.RecipientType;
import pro.thinhha.mailsender.pgp.discovery.DiscoveryResult;
import pro.thinhha.mailsender.pgp.sender.SecureMailSender;
import pro.thinhha.mailsender.pgp.sender.SendReport;
import pro.thinhha.mailsender.pgp.sender.SendReport.Outcome;
import pro.thinhha.mailsender.pgp.sender.SendReport.PlaintextReason;
class MeteredSecureMailSenderTest {
private final SimpleMeterRegistry registry = new SimpleMeterRegistry();
@Test
void countsRecipientsByOutcomePolicyAndReason() {
SendReport report = new SendReport(List.of(
outcome("a@example.org", Outcome.ENCRYPTED, PlaintextReason.NONE),
outcome("b@example.org", Outcome.ENCRYPTED, PlaintextReason.NONE),
outcome("c@example.org", Outcome.PLAINTEXT, PlaintextReason.NOT_FOUND)));
new MeteredSecureMailSender(sender(report, null), registry).send(new SimpleMailMessage());
assertThat(count("encrypted", "none")).isEqualTo(2);
assertThat(count("plaintext", "not_found")).isEqualTo(1);
}
@Test
void countsRefusals() {
SecureMailSender failing = sender(null, new EncryptionPolicyException("no key", List.of("x@y.z")));
assertThatThrownBy(() -> new MeteredSecureMailSender(failing, registry).send(new SimpleMailMessage()))
.isInstanceOf(EncryptionPolicyException.class);
assertThat(registry.get(MeteredSecureMailSender.REFUSED).counter().count()).isEqualTo(1);
}
@Test
void timesKeyLookupsWithResultAndSource() {
new MeteredKeyDiscovery(email -> DiscoveryResult.notFound(), registry).discover("a@example.org");
assertThat(registry.get(MeteredKeyDiscovery.LOOKUP).tag("result", "not_found").tag("source", "none")
.timer().count()).isEqualTo(1);
}
private double count(String outcome, String reason) {
return registry.get(MeteredSecureMailSender.RECIPIENTS)
.tag("outcome", outcome).tag("policy", "opportunistic").tag("reason", reason)
.counter().count();
}
private static SendReport.RecipientOutcome outcome(String address, Outcome outcome, PlaintextReason reason) {
return new SendReport.RecipientOutcome(address, RecipientType.TO, EncryptionPolicy.OPPORTUNISTIC, outcome,
reason, null);
}
private static SecureMailSender sender(SendReport report, RuntimeException failure) {
return new SecureMailSender() {
@Override
public MimeMessage createMimeMessage() {
throw new UnsupportedOperationException();
}
@Override
public SendReport send(MimeMessage message) {
return send((SimpleMailMessage) null);
}
@Override
public SendReport send(SimpleMailMessage message) {
if (failure != null) {
throw failure;
}
return report;
}
};
}
}
@@ -0,0 +1,170 @@
package pro.thinhha.mailsender.pgp.sender;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import org.bouncycastle.openpgp.api.OpenPGPKey;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.pgpainless.PGPainless;
import org.pgpainless.decryption_verification.ConsumerOptions;
import org.pgpainless.decryption_verification.DecryptionStream;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.mail.autoconfigure.MailSenderAutoConfiguration;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.mail.SimpleMailMessage;
import com.icegreen.greenmail.util.GreenMail;
import com.icegreen.greenmail.util.ServerSetupTest;
import jakarta.mail.Message;
import jakarta.mail.Multipart;
import jakarta.mail.internet.MimeMessage;
import pro.thinhha.mailsender.pgp.autoconfigure.PgpMailAutoConfiguration;
import pro.thinhha.mailsender.pgp.delivery.EncryptionPolicyException;
/** End to end through the auto-configured {@link SecureMailSender} into GreenMail. WKD is off; keys are pinned. */
class SecureMailSenderIntegrationTest {
private static final PGPainless API = PGPainless.getInstance();
private static final OpenPGPKey ALICE = API.generateKey().modernKeyRing("<alice@example.org>");
private static final OpenPGPKey DAVE = API.generateKey().modernKeyRing("<dave@example.org>");
private static final OpenPGPKey SENDER = API.generateKey().modernKeyRing("<app@example.com>");
@TempDir
Path keys;
private GreenMail greenMail;
private ApplicationContextRunner runner;
@BeforeEach
void setUp() throws Exception {
greenMail = new GreenMail(ServerSetupTest.SMTP.dynamicPort());
greenMail.start();
runner = new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(MailSenderAutoConfiguration.class,
PgpMailAutoConfiguration.class))
.withPropertyValues(
"spring.mail.host=127.0.0.1",
"spring.mail.port=" + greenMail.getSmtp().getPort(),
"mail.pgp.wkd.enabled=false",
"mail.pgp.static-keys[alice@example.org]=file:" + write("alice", ALICE),
"mail.pgp.static-keys[dave@example.org]=file:" + write("dave", DAVE),
"mail.pgp.sender-keys[app@example.com]=file:" + write("app", SENDER),
"mail.pgp.recipients[*@partner.example]=REQUIRE");
}
@AfterEach
void tearDown() {
greenMail.stop();
}
@Test
void splitsMixedRecipientsAndReportsEachOne() {
runner.run(context -> {
SecureMailSender sender = context.getBean(SecureMailSender.class);
SimpleMailMessage message = new SimpleMailMessage();
message.setFrom("app@example.com");
message.setTo("alice@example.org", "carol@example.org");
message.setBcc("dave@example.org");
message.setSubject("Report");
message.setText("top secret");
SendReport report = sender.send(message);
assertThat(report.recipients()).extracting(SendReport.RecipientOutcome::address,
SendReport.RecipientOutcome::outcome).containsExactly(
org.assertj.core.groups.Tuple.tuple("alice@example.org", SendReport.Outcome.ENCRYPTED),
org.assertj.core.groups.Tuple.tuple("carol@example.org", SendReport.Outcome.PLAINTEXT),
org.assertj.core.groups.Tuple.tuple("dave@example.org", SendReport.Outcome.ENCRYPTED));
assertThat(report.recipients().get(1).detail()).isEqualTo("NOT_FOUND");
MimeMessage alice = inbox("alice@example.org");
MimeMessage carol = inbox("carol@example.org");
MimeMessage dave = inbox("dave@example.org");
assertThat(greenMail.getReceivedMessages()).hasSize(3);
assertThat(alice.isMimeType("multipart/encrypted")).isTrue();
assertThat(decrypt(alice, ALICE)).contains("top secret");
assertThat(decrypt(alice, SENDER)).as("sender can read sent copy").contains("top secret");
assertThat(carol.isMimeType("text/plain")).isTrue();
assertThat(carol.getContent().toString()).contains("top secret");
assertThat(decrypt(dave, DAVE)).contains("top secret");
for (MimeMessage received : List.of(alice, carol, dave)) {
assertThat(received.getHeader("Bcc")).isNull();
assertThat(received.getRecipients(Message.RecipientType.TO)).hasSize(2);
assertThat(received.getMessageID()).isEqualTo(alice.getMessageID());
}
});
}
@Test
void refusesRequiredRecipientWithoutKeyAndSendsNothing() {
runner.run(context -> {
SimpleMailMessage message = new SimpleMailMessage();
message.setFrom("app@example.com");
message.setTo("alice@example.org", "ceo@partner.example");
message.setText("x");
assertThatThrownBy(() -> context.getBean(SecureMailSender.class).send(message))
.isInstanceOf(EncryptionPolicyException.class).hasMessageContaining("ceo@partner.example");
assertThat(greenMail.getReceivedMessages()).isEmpty();
});
}
@Test
void relayModeSendsPlaintextWithPolicyHint() {
runner.withPropertyValues(
"mail.pgp.mode=RELAY",
"mail.pgp.relay.host=127.0.0.1",
"mail.pgp.relay.port=" + greenMail.getSmtp().getPort(),
"mail.pgp.relay.starttls=false",
"mail.pgp.relay.policy-header=X-Encryption-Policy")
.run(context -> {
SimpleMailMessage message = new SimpleMailMessage();
message.setFrom("app@example.com");
message.setTo("alice@example.org", "ceo@partner.example");
message.setText("via gateway");
SendReport report = context.getBean(SecureMailSender.class).send(message);
assertThat(report.recipients()).allMatch(r -> r.outcome() == SendReport.Outcome.RELAYED);
MimeMessage received = greenMail.getReceivedMessages()[0];
assertThat(received.getHeader("X-Encryption-Policy")).containsExactly("require");
assertThat(received.getContent().toString()).contains("via gateway");
});
}
private Path write(String name, OpenPGPKey key) throws Exception {
Path file = keys.resolve(name + ".asc");
Files.writeString(file, key.toCertificate().toAsciiArmoredString());
return file;
}
/** The single message delivered to this address's mailbox, i.e. via the SMTP envelope. */
private MimeMessage inbox(String address) throws Exception {
var user = greenMail.getUserManager().getUserByEmail(address);
assertThat(user).as("mailbox for " + address).isNotNull();
var stored = greenMail.getManagers().getImapHostManager().getInbox(user).getMessages();
assertThat(stored).hasSize(1);
return stored.getFirst().getMimeMessage();
}
private static String decrypt(MimeMessage message, OpenPGPKey key) throws Exception {
byte[] armored = ((Multipart) message.getContent()).getBodyPart(1).getInputStream().readAllBytes();
ByteArrayOutputStream out = new ByteArrayOutputStream();
try (DecryptionStream in = API.processMessage().onInputStream(new ByteArrayInputStream(armored))
.withOptions(ConsumerOptions.get(API).addDecryptionKey(key))) {
in.transferTo(out);
}
return out.toString(java.nio.charset.StandardCharsets.UTF_8);
}
}
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>pro.thinhha.mailsender</groupId>
<artifactId>mailsender-parent</artifactId>
<version>0.1.0-SNAPSHOT</version>
</parent>
<artifactId>mailsender-pgp-spring-boot-starter</artifactId>
<description>Starter: OpenPGP-encrypted mail for Spring Boot</description>
<dependencies>
<dependency>
<groupId>pro.thinhha.mailsender</groupId>
<artifactId>mailsender-pgp-spring-boot-autoconfigure</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
</dependencies>
</project>
+121
View File
@@ -0,0 +1,121 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>pro.thinhha.mailsender</groupId>
<artifactId>mailsender-parent</artifactId>
<version>0.1.0-SNAPSHOT</version>
<packaging>pom</packaging>
<name>mailsender</name>
<description>OpenPGP (WKD + PGP/MIME) encryption for Spring Boot mail</description>
<modules>
<module>mailsender-pgp-core</module>
<module>mailsender-pgp-spring-boot-autoconfigure</module>
<module>mailsender-pgp-spring-boot-starter</module>
<module>mailsender-demo</module>
</modules>
<properties>
<maven.compiler.release>27</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<spring-boot.version>4.1.1</spring-boot.version>
<pgpainless.version>2.0.4</pgpainless.version>
<wkd-java.version>0.1.3</wkd-java.version>
<caffeine.version>3.3.0</caffeine.version>
<greenmail.version>2.1.14</greenmail.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring-boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>pro.thinhha.mailsender</groupId>
<artifactId>mailsender-pgp-core</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>pro.thinhha.mailsender</groupId>
<artifactId>mailsender-pgp-spring-boot-autoconfigure</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>pro.thinhha.mailsender</groupId>
<artifactId>mailsender-pgp-spring-boot-starter</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.pgpainless</groupId>
<artifactId>pgpainless-core</artifactId>
<version>${pgpainless.version}</version>
</dependency>
<dependency>
<groupId>org.pgpainless</groupId>
<artifactId>wkd-java</artifactId>
<version>${wkd-java.version}</version>
</dependency>
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
<version>${caffeine.version}</version>
</dependency>
<dependency>
<groupId>com.icegreen</groupId>
<artifactId>greenmail-junit5</artifactId>
<version>${greenmail.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
<build>
<pluginManagement>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.14.0</version>
<configuration>
<parameters>true</parameters>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.5.3</version>
</plugin>
</plugins>
</pluginManagement>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-enforcer-plugin</artifactId>
<version>3.5.0</version>
<executions>
<execution>
<id>enforce-java</id>
<goals>
<goal>enforce</goal>
</goals>
<configuration>
<rules>
<requireJavaVersion>
<version>[27,)</version>
</requireJavaVersion>
</rules>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>