mirror of
https://github.com/sneakybugmaster/mailsender.git
synced 2026-11-10 19:48:49 +00:00
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:
79 files changed
+4925
No files matched your search
@@ -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/'
|
||||
@@ -0,0 +1,5 @@
|
||||
target/
|
||||
.idea/
|
||||
*.iml
|
||||
.vscode/
|
||||
.DS_Store
|
||||
@@ -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
@@ -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.
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
+12
@@ -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);
|
||||
}
|
||||
}
|
||||
+140
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -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
|
||||
}
|
||||
+19
@@ -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;
|
||||
}
|
||||
}
|
||||
+35
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+66
@@ -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
|
||||
}
|
||||
+44
@@ -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);
|
||||
}
|
||||
}
|
||||
+24
@@ -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;
|
||||
}
|
||||
}
|
||||
+37
@@ -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
|
||||
}
|
||||
}
|
||||
+46
@@ -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));
|
||||
}
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
package pro.thinhha.mailsender.pgp.delivery;
|
||||
|
||||
/** Header a recipient was addressed in. */
|
||||
public enum RecipientType {
|
||||
TO, CC, BCC
|
||||
}
|
||||
+20
@@ -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();
|
||||
}
|
||||
}
|
||||
+38
@@ -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();
|
||||
}
|
||||
}
|
||||
+121
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+23
@@ -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);
|
||||
}
|
||||
}
|
||||
+34
@@ -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();
|
||||
}
|
||||
}
|
||||
+36
@@ -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();
|
||||
}
|
||||
}
|
||||
+11
@@ -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
|
||||
}
|
||||
+22
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
+64
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+19
@@ -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);
|
||||
}
|
||||
+34
@@ -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);
|
||||
}
|
||||
}
|
||||
+15
@@ -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
|
||||
}
|
||||
+21
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+96
@@ -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;
|
||||
}
|
||||
}
|
||||
+22
@@ -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();
|
||||
}
|
||||
}
|
||||
+184
@@ -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();
|
||||
}
|
||||
}
|
||||
+100
@@ -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);
|
||||
}
|
||||
}
|
||||
+40
@@ -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
|
||||
}
|
||||
}
|
||||
+57
@@ -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();
|
||||
}
|
||||
}
|
||||
+167
@@ -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();
|
||||
}
|
||||
}
|
||||
+93
@@ -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();
|
||||
}
|
||||
}
|
||||
+35
@@ -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)));
|
||||
}
|
||||
}
|
||||
+109
@@ -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);
|
||||
}
|
||||
}
|
||||
+44
@@ -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);
|
||||
}
|
||||
}
|
||||
+85
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+21
@@ -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");
|
||||
}
|
||||
}
|
||||
+160
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
+168
@@ -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;
|
||||
}
|
||||
}
|
||||
+102
@@ -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>
|
||||
+12
@@ -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);
|
||||
}
|
||||
+121
@@ -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) {
|
||||
}
|
||||
}
|
||||
+224
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+29
@@ -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);
|
||||
}
|
||||
}
|
||||
+53
@@ -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));
|
||||
}
|
||||
}
|
||||
+98
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
@@ -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);
|
||||
}
|
||||
+41
@@ -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;
|
||||
}
|
||||
}
|
||||
+78
@@ -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);
|
||||
}
|
||||
}
|
||||
+41
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+89
@@ -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";
|
||||
};
|
||||
}
|
||||
}
|
||||
+72
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
+25
@@ -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);
|
||||
}
|
||||
+48
@@ -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
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pro.thinhha.mailsender.pgp.autoconfigure.PgpMailAutoConfiguration
|
||||
pro.thinhha.mailsender.pgp.autoconfigure.PgpMailMetricsAutoConfiguration
|
||||
pro.thinhha.mailsender.pgp.autoconfigure.PgpMailEndpointAutoConfiguration
|
||||
+56
@@ -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\"");
|
||||
}
|
||||
}
|
||||
+126
@@ -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));
|
||||
}
|
||||
}
|
||||
+94
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
+88
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
+170
@@ -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>
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user