Add certificate validation and PGP/MIME encryption; rename to pro.thinhha.mailsender

- validation: CertificateValidator checks revocation, expiry, matching
  user ID, a valid communications encryption subkey and the algorithm
  policy; uses the earlier expiry when PGPainless and Bouncy Castle disagree
- crypto: PgpEncryptor (ASCII armor, hidden recipients for Bcc)
- mime: PgpMimeEncryptor builds RFC 3156 multipart/encrypted from the
  whole CRLF-canonical inner entity, keeping envelope headers in the clear
- autoconfigure: validator and encryptor beans
- demo: encrypts when a valid key is found, plaintext otherwise
- rename groupId and Java packages to pro.thinhha.mailsender

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G71TQ5jDETVwdUJxGAwaiT
This commit is contained in:
Claude committed 2026-10-12 03:20:33 +00:00
1 parent 213df4c34e
commit a788f7a48a
44 files changed
+958 -142

No files matched your search

+6 -5
View File
@@ -14,16 +14,17 @@ mvn verify
| Module | Contents |
|---|---|
| `mailsender-pgp-core` | WKD key discovery, caching, pinned keys (no Spring) |
| `mailsender-pgp-spring-boot-autoconfigure` | `mail.pgp.*` properties and discovery beans |
| `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 |
## Demo app
The demo app looks up the recipient's key via WKD, logs the result, and sends `DEMO_COUNT` test mails
(default: 1 to `test@thinhha.pro`). Encryption is not wired in yet, so the mails are sent **in plaintext**,
and the body says whether a key was found.
The demo app looks up and validates the recipient's key via WKD, then sends `DEMO_COUNT` test mails
(default: 1 to `test@thinhha.pro`). With a valid key the mail is PGP/MIME-encrypted (RFC 3156); otherwise it is
sent in plaintext and a warning is logged. The policy layer (REQUIRE / OPPORTUNISTIC / PLAINTEXT, split
delivery) comes in milestone 5.
```
mvn -q install -DskipTests
+5 -3
View File
@@ -164,12 +164,14 @@ mail.pgp:
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, sign-only, wrong UID, valid.
4. **Encryptor + PGP/MIME** — round-trip decrypt test, golden structure test, CRLF canonicalization.
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** — table-driven tests over key/no-key × REQUIRE/OPPORTUNISTIC/PLAINTEXT × To/Cc/Bcc;
`EnvelopeMimeMessage`.
6. **SecureMailSender (BUILT_IN + RELAY)**, `SendReport`, sender keys, metrics, autoconfig switch.
7. **Demo + E2E** with GreenMail; manual check against a real WKD domain, decrypt in Thunderbird.
Demo app scaffolded early (sends plaintext test mails + reports WKD result) ✅; verified live:
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** — protected headers, actuator endpoint, docs.
+2 -2
View File
@@ -5,7 +5,7 @@
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>io.github.sneakybugmaster.mailsender</groupId>
<groupId>pro.thinhha.mailsender</groupId>
<artifactId>mailsender-parent</artifactId>
<version>0.1.0-SNAPSHOT</version>
</parent>
@@ -15,7 +15,7 @@
<dependencies>
<dependency>
<groupId>io.github.sneakybugmaster.mailsender</groupId>
<groupId>pro.thinhha.mailsender</groupId>
<artifactId>mailsender-pgp-spring-boot-starter</artifactId>
</dependency>
<dependency>
@@ -1,76 +0,0 @@
package io.github.sneakybugmaster.mailsender.demo;
import java.time.OffsetDateTime;
import java.util.HexFormat;
import java.util.stream.Collectors;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Component;
import io.github.sneakybugmaster.mailsender.pgp.discovery.DiscoveryResult;
import io.github.sneakybugmaster.mailsender.pgp.discovery.KeyDiscovery;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
/**
* Looks up the recipient's key via WKD, then sends {@code demo.count} test mails describing what was found.
* <p>
* Encryption is not wired in yet (milestones 3–6), so the mails go out in plaintext.
*/
@Component
class TestMailRunner implements ApplicationRunner {
private static final Logger log = LoggerFactory.getLogger(TestMailRunner.class);
private final DemoProperties properties;
private final KeyDiscovery keyDiscovery;
private final JavaMailSender mailSender;
TestMailRunner(DemoProperties properties, KeyDiscovery keyDiscovery, JavaMailSender mailSender) {
this.properties = properties;
this.keyDiscovery = keyDiscovery;
this.mailSender = mailSender;
}
@Override
public void run(ApplicationArguments args) throws MessagingException {
DiscoveryResult discovery = keyDiscovery.discover(properties.to());
String summary = describe(discovery);
log.info("Key discovery for {}: {}", properties.to(), summary);
for (int i = 1; i <= properties.count(); i++) {
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, "UTF-8");
if (properties.from() != null) {
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.
Key discovery for %s: %s
This message was sent in plaintext: PGP/MIME encryption is not implemented yet.
""".formatted(i, properties.count(), OffsetDateTime.now(), properties.to(), summary));
mailSender.send(message);
log.info("Sent test mail {}/{} to {}", i, properties.count(), properties.to());
}
}
private static String describe(DiscoveryResult result) {
return switch (result) {
case DiscoveryResult.Found found -> "found via " + found.source() + ", fingerprint(s) "
+ found.certificates().stream()
.map(c -> HexFormat.of().withUpperCase().formatHex(c.getFingerprint()))
.collect(Collectors.joining(", "));
case DiscoveryResult.NotFound ignored -> "no key published";
case DiscoveryResult.Unreachable u -> "lookup failed (" + u.reason() + ")";
};
}
}
@@ -1,4 +1,4 @@
package io.github.sneakybugmaster.mailsender.demo;
package pro.thinhha.mailsender.demo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@@ -1,4 +1,4 @@
package io.github.sneakybugmaster.mailsender.demo;
package pro.thinhha.mailsender.demo;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;
@@ -0,0 +1,123 @@
package pro.thinhha.mailsender.demo;
import java.io.IOException;
import java.time.OffsetDateTime;
import java.util.HexFormat;
import java.util.List;
import java.util.Optional;
import org.bouncycastle.openpgp.api.OpenPGPCertificate;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.mail.javamail.JavaMailSender;
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.discovery.DiscoveryResult;
import pro.thinhha.mailsender.pgp.discovery.KeyDiscovery;
import pro.thinhha.mailsender.pgp.mime.PgpMimeEncryptor;
import pro.thinhha.mailsender.pgp.validation.CertificateValidator;
import pro.thinhha.mailsender.pgp.validation.ValidationResult;
/**
* Looks up and validates the recipient's key, then sends {@code demo.count} test mails: PGP/MIME-encrypted when a
* valid key was found, otherwise in plaintext (logged as a downgrade).
* <p>
* This calls the building blocks directly; the policy layer and {@code SecureMailSender} (milestones 5–6) will
* replace this wiring.
*/
@Component
class TestMailRunner implements ApplicationRunner {
private static final Logger log = LoggerFactory.getLogger(TestMailRunner.class);
private final DemoProperties properties;
private final KeyDiscovery keyDiscovery;
private final CertificateValidator validator;
private final PgpMimeEncryptor mimeEncryptor;
private final JavaMailSender mailSender;
TestMailRunner(DemoProperties properties, KeyDiscovery keyDiscovery, CertificateValidator validator,
PgpMimeEncryptor mimeEncryptor, JavaMailSender mailSender) {
this.properties = properties;
this.keyDiscovery = keyDiscovery;
this.validator = validator;
this.mimeEncryptor = mimeEncryptor;
this.mailSender = mailSender;
}
@Override
public void run(ApplicationArguments args) throws MessagingException, IOException {
String to = properties.to();
DiscoveryResult discovery = keyDiscovery.discover(to);
Optional<OpenPGPCertificate> key = usableKey(discovery, to);
for (int i = 1; i <= properties.count(); i++) {
MimeMessage message = compose(i, discovery, key.isPresent());
if (key.isPresent()) {
message = mimeEncryptor.encrypt(message, List.of(key.get()), List.of(), mailSender::createMimeMessage);
log.info("Sending test mail {}/{} to {} ENCRYPTED to {}", i, properties.count(), to,
fingerprint(key.get()));
} else {
log.warn("Sending test mail {}/{} to {} in PLAINTEXT: no usable key", i, properties.count(), to);
}
mailSender.send(message);
}
}
private Optional<OpenPGPCertificate> usableKey(DiscoveryResult discovery, String to) {
switch (discovery) {
case DiscoveryResult.Found found -> {
for (OpenPGPCertificate certificate : found.certificates()) {
ValidationResult result = validator.validate(certificate, to);
switch (result) {
case ValidationResult.Valid valid -> {
log.info("Key for {} found via {}: {}", to, found.source(), fingerprint(certificate));
return Optional.of(valid.certificate());
}
case ValidationResult.Rejected rejected -> log.warn("Key {} for {} rejected: {} ({})",
fingerprint(certificate), to, rejected.reason(), rejected.detail());
}
}
}
case DiscoveryResult.NotFound ignored -> log.info("No key published for {}", to);
case DiscoveryResult.Unreachable unreachable ->
log.warn("Key lookup for {} failed: {}", to, unreachable.reason());
}
return Optional.empty();
}
private MimeMessage compose(int index, DiscoveryResult discovery, boolean encrypted) throws MessagingException {
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(index, properties.count()));
helper.setText("""
Test mail %d of %d from the mailsender demo, sent %s.
Key discovery for %s: %s
This message was sent %s.
""".formatted(index, properties.count(), OffsetDateTime.now(), properties.to(),
describe(discovery), encrypted ? "PGP/MIME-encrypted" : "in plaintext"));
return message;
}
private static String describe(DiscoveryResult result) {
return switch (result) {
case DiscoveryResult.Found found -> "found via " + found.source();
case DiscoveryResult.NotFound ignored -> "no key published";
case DiscoveryResult.Unreachable u -> "lookup failed (" + u.reason() + ")";
};
}
private static String fingerprint(OpenPGPCertificate certificate) {
return HexFormat.of().withUpperCase().formatHex(certificate.getFingerprint());
}
}
@@ -20,4 +20,4 @@ demo:
logging:
level:
io.github.sneakybugmaster.mailsender: DEBUG
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("found via STATIC", "PGP/MIME-encrypted");
}
}
@@ -1,4 +1,4 @@
package io.github.sneakybugmaster.mailsender.demo;
package pro.thinhha.mailsender.demo;
import static org.assertj.core.api.Assertions.assertThat;
@@ -25,7 +25,7 @@ import jakarta.mail.internet.MimeMessage;
// keep the test offline: no real WKD lookup
"mail.pgp.wkd.enabled=false"
})
class TestMailRunnerTest {
class PlaintextTestMailTest {
// Started before the Spring context, because the runner sends while the context starts.
static GreenMail greenMail;
@@ -54,6 +54,6 @@ class TestMailRunnerTest {
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("no key published");
assertThat(GreenMailUtil.getBody(received[0])).contains("no key published", "in plaintext");
}
}
+6 -1
View File
@@ -5,7 +5,7 @@
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>io.github.sneakybugmaster.mailsender</groupId>
<groupId>pro.thinhha.mailsender</groupId>
<artifactId>mailsender-parent</artifactId>
<version>0.1.0-SNAPSHOT</version>
</parent>
@@ -30,6 +30,11 @@
<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>
@@ -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();
}
}
@@ -1,4 +1,4 @@
package io.github.sneakybugmaster.mailsender.pgp.discovery;
package pro.thinhha.mailsender.pgp.discovery;
import java.time.Clock;
import java.time.Duration;
@@ -1,4 +1,4 @@
package io.github.sneakybugmaster.mailsender.pgp.discovery;
package pro.thinhha.mailsender.pgp.discovery;
import java.io.IOException;
import java.util.List;
@@ -1,4 +1,4 @@
package io.github.sneakybugmaster.mailsender.pgp.discovery;
package pro.thinhha.mailsender.pgp.discovery;
import java.util.List;
@@ -1,4 +1,4 @@
package io.github.sneakybugmaster.mailsender.pgp.discovery;
package pro.thinhha.mailsender.pgp.discovery;
import java.util.List;
import java.util.Objects;
@@ -1,4 +1,4 @@
package io.github.sneakybugmaster.mailsender.pgp.discovery;
package pro.thinhha.mailsender.pgp.discovery;
/** Where a certificate came from. */
public enum DiscoverySource {
@@ -1,4 +1,4 @@
package io.github.sneakybugmaster.mailsender.pgp.discovery;
package pro.thinhha.mailsender.pgp.discovery;
import java.net.InetAddress;
import java.net.UnknownHostException;
@@ -1,4 +1,4 @@
package io.github.sneakybugmaster.mailsender.pgp.discovery;
package pro.thinhha.mailsender.pgp.discovery;
import java.io.IOException;
import java.io.InputStream;
@@ -1,4 +1,4 @@
package io.github.sneakybugmaster.mailsender.pgp.discovery;
package pro.thinhha.mailsender.pgp.discovery;
/**
* Finds the OpenPGP certificate(s) published for an e-mail address.
@@ -1,4 +1,4 @@
package io.github.sneakybugmaster.mailsender.pgp.discovery;
package pro.thinhha.mailsender.pgp.discovery;
import java.util.HashMap;
import java.util.List;
@@ -1,4 +1,4 @@
package io.github.sneakybugmaster.mailsender.pgp.discovery;
package pro.thinhha.mailsender.pgp.discovery;
/** When to fall back from the WKD advanced method to the direct method. */
public enum WkdFallback {
@@ -1,4 +1,4 @@
package io.github.sneakybugmaster.mailsender.pgp.discovery;
package pro.thinhha.mailsender.pgp.discovery;
import java.io.IOException;
import java.net.URI;
@@ -1,4 +1,4 @@
package io.github.sneakybugmaster.mailsender.pgp.discovery;
package pro.thinhha.mailsender.pgp.discovery;
import java.io.IOException;
import java.net.URI;
@@ -0,0 +1,22 @@
package pro.thinhha.mailsender.pgp.mime;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.ContentType;
import jakarta.mail.internet.MimeMultipart;
/**
* {@code multipart/encrypted; protocol="application/pgp-encrypted"} (RFC 3156). A plain
* {@code new MimeMultipart("encrypted")} cannot carry the {@code protocol} parameter: {@code saveChanges()}
* regenerates the Content-Type from the multipart, so the parameter has to live in the multipart itself.
*/
final class EncryptedMultipart extends MimeMultipart {
static final String PROTOCOL = "application/pgp-encrypted";
EncryptedMultipart() throws MessagingException {
super("encrypted");
ContentType type = new ContentType(contentType);
type.setParameter("protocol", PROTOCOL);
contentType = type.toString();
}
}
@@ -0,0 +1,138 @@
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.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.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.
*/
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);
private final PgpEncryptor encryptor;
public PgpMimeEncryptor(PgpEncryptor encryptor) {
this.encryptor = Objects.requireNonNull(encryptor, "encryptor");
}
/**
* @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), recipients, hiddenRecipients);
MimeMessage outer = newMessage.get();
copyEnvelopeHeaders(source, outer);
outer.setContent(pgpMimeBody(ciphertext));
outer.saveChanges();
return outer;
}
/** Serializes and re-parses the message so headers are final and the encoded body is available. */
private static MimeMessage snapshot(MimeMessage original) throws MessagingException, IOException {
original.saveChanges();
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
original.writeTo(buffer);
return new MimeMessage(PARSE_SESSION, new ByteArrayInputStream(buffer.toByteArray()));
}
/** The {@code Content-*} headers plus the still-encoded body, with CRLF line endings. */
static byte[] innerEntity(MimeMessage source) throws MessagingException, IOException {
ByteArrayOutputStream entity = new ByteArrayOutputStream();
Enumeration<String> lines = source.getAllHeaderLines();
while (lines.hasMoreElements()) {
String line = lines.nextElement();
if (isContentHeader(line)) {
entity.write(line.getBytes(StandardCharsets.UTF_8));
entity.write('\r');
entity.write('\n');
}
}
entity.write('\r');
entity.write('\n');
try (InputStream body = source.getRawInputStream()) {
body.transferTo(entity);
}
return toCrlf(entity.toByteArray());
}
private static void copyEnvelopeHeaders(MimeMessage source, MimeMessage outer) throws MessagingException {
Enumeration<Header> headers = source.getAllHeaders();
while (headers.hasMoreElements()) {
Header header = headers.nextElement();
String name = header.getName().toLowerCase(Locale.ROOT);
if (!name.startsWith("content-") && !name.equals("mime-version") && !name.equals("message-id")) {
outer.addHeader(header.getName(), header.getValue());
}
}
}
private static EncryptedMultipart pgpMimeBody(byte[] ciphertext) throws MessagingException {
MimeBodyPart control = new MimeBodyPart();
control.setDataHandler(new DataHandler(new ByteArrayDataSource(VERSION_PART, EncryptedMultipart.PROTOCOL)));
control.setHeader("Content-Description", "PGP/MIME version identification");
control.setHeader("Content-Transfer-Encoding", "7bit");
MimeBodyPart encrypted = new MimeBodyPart();
encrypted.setDataHandler(new DataHandler(
new ByteArrayDataSource(ciphertext, "application/octet-stream; name=\"encrypted.asc\"")));
encrypted.setHeader("Content-Description", "OpenPGP encrypted message");
encrypted.setHeader("Content-Disposition", "inline; filename=\"encrypted.asc\"");
encrypted.setHeader("Content-Transfer-Encoding", "7bit");
EncryptedMultipart multipart = new EncryptedMultipart();
multipart.setPreamble("This is an OpenPGP/MIME encrypted message (RFC 4880 and 3156)");
multipart.addBodyPart(control);
multipart.addBodyPart(encrypted);
return multipart;
}
private static boolean isContentHeader(String headerLine) {
return headerLine.regionMatches(true, 0, "content-", 0, "content-".length());
}
/** RFC 3156 requires the encrypted entity in canonical form: every line ends in CRLF. */
static byte[] toCrlf(byte[] data) {
ByteArrayOutputStream out = new ByteArrayOutputStream(data.length + data.length / 50);
for (int i = 0; i < data.length; i++) {
byte b = data[i];
if (b == '\n' && (i == 0 || data[i - 1] != '\r')) {
out.write('\r');
}
out.write(b);
}
return out.toByteArray();
}
}
@@ -0,0 +1,100 @@
package pro.thinhha.mailsender.pgp.validation;
import java.time.Clock;
import java.util.Date;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import org.bouncycastle.openpgp.api.OpenPGPCertificate;
import org.bouncycastle.openpgp.api.OpenPGPCertificate.OpenPGPComponentKey;
import org.pgpainless.PGPainless;
import org.pgpainless.algorithm.EncryptionPurpose;
import org.pgpainless.key.info.KeyRingInfo;
import org.pgpainless.policy.Policy;
/**
* Decides whether a discovered certificate may be used to encrypt mail to an address. A certificate is
* {@link ValidationResult.Valid} only if its primary key is neither revoked nor expired, a valid User ID contains
* the address, and at least one valid subkey for encrypting communications passes the algorithm policy.
*/
public final class CertificateValidator {
private final PGPainless api;
private final Clock clock;
public CertificateValidator() {
this(PGPainless.getInstance(), Clock.systemUTC());
}
public CertificateValidator(PGPainless api, Clock clock) {
this.api = Objects.requireNonNull(api, "api");
this.clock = Objects.requireNonNull(clock, "clock");
}
public ValidationResult validate(OpenPGPCertificate certificate, String email) {
Date now = Date.from(clock.instant());
try {
KeyRingInfo info = api.inspect(certificate, now);
// PGPainless and Bouncy Castle can disagree on expiry after it was changed; trust the earlier one.
Date expiration = earliest(info.getPrimaryKeyExpirationDate(), certificate.getExpirationTime(now));
if (expiration != null && !expiration.after(now)) {
return reject(certificate, ValidationResult.Reason.EXPIRED, "expired " + expiration.toInstant());
}
if (!info.getRevocationState().isNotRevoked() || !certificate.getPrimaryKey().isBoundAt(now)) {
return reject(certificate, ValidationResult.Reason.REVOKED, "primary key revoked");
}
if (!hasUserIdFor(info.getValidUserIds(), email)) {
return reject(certificate, ValidationResult.Reason.USER_ID_MISMATCH,
"no valid user ID for " + email + " in " + info.getUserIds());
}
List<OpenPGPComponentKey> encryptionKeys = info.getEncryptionSubkeys(EncryptionPurpose.COMMUNICATIONS);
if (encryptionKeys.isEmpty()) {
return reject(certificate, ValidationResult.Reason.NO_ENCRYPTION_SUBKEY,
"no valid subkey for encrypting communications");
}
Policy.PublicKeyAlgorithmPolicy algorithmPolicy = api.getAlgorithmPolicy().getPublicKeyAlgorithmPolicy();
boolean acceptable = encryptionKeys.stream().anyMatch(key ->
algorithmPolicy.isAcceptable(key.getAlgorithm(), key.getPGPPublicKey().getBitStrength()));
if (!acceptable) {
return reject(certificate, ValidationResult.Reason.WEAK_ALGORITHM,
"encryption subkeys rejected by algorithm policy");
}
return new ValidationResult.Valid(certificate);
} catch (RuntimeException e) {
return reject(certificate, ValidationResult.Reason.INVALID, String.valueOf(e.getMessage()));
}
}
/**
* Matches {@code email} against the address in each User ID, either the {@code <...>} part of
* {@code Name <addr>} or the whole User ID. Comparison ignores case: WKD already lowercases the local part.
*/
static boolean hasUserIdFor(List<String> userIds, String email) {
String wanted = email.strip().toLowerCase(Locale.ROOT);
return userIds.stream()
.map(CertificateValidator::addressOf)
.anyMatch(wanted::equals);
}
private static String addressOf(String userId) {
int open = userId.lastIndexOf('<');
int close = userId.lastIndexOf('>');
String address = open >= 0 && close > open ? userId.substring(open + 1, close) : userId;
return address.strip().toLowerCase(Locale.ROOT);
}
private static Date earliest(Date a, Date b) {
if (a == null) {
return b;
}
return b == null || a.before(b) ? a : b;
}
private static ValidationResult reject(OpenPGPCertificate certificate, ValidationResult.Reason reason,
String detail) {
return new ValidationResult.Rejected(certificate, reason, detail);
}
}
@@ -0,0 +1,40 @@
package pro.thinhha.mailsender.pgp.validation;
import java.util.Objects;
import org.bouncycastle.openpgp.api.OpenPGPCertificate;
/** Outcome of {@link CertificateValidator#validate}. */
public sealed interface ValidationResult {
OpenPGPCertificate certificate();
/** The certificate can be used to encrypt mail to the address it was validated for. */
record Valid(OpenPGPCertificate certificate) implements ValidationResult {
public Valid {
Objects.requireNonNull(certificate, "certificate");
}
}
record Rejected(OpenPGPCertificate certificate, Reason reason, String detail) implements ValidationResult {
public Rejected {
Objects.requireNonNull(certificate, "certificate");
Objects.requireNonNull(reason, "reason");
}
}
enum Reason {
/** The primary key is revoked. */
REVOKED,
/** The primary key is expired. */
EXPIRED,
/** No valid User ID carries the address. */
USER_ID_MISMATCH,
/** No valid, non-expired subkey is marked for encrypting communications. */
NO_ENCRYPTION_SUBKEY,
/** Encryption subkeys exist, but their algorithm or size is rejected by the algorithm policy. */
WEAK_ALGORITHM,
/** The certificate could not be evaluated (bad self-signatures, unsupported packets, ...). */
INVALID
}
}
@@ -0,0 +1,57 @@
package pro.thinhha.mailsender.pgp.crypto;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.List;
import org.bouncycastle.openpgp.api.OpenPGPKey;
import org.junit.jupiter.api.Test;
import org.pgpainless.PGPainless;
import org.pgpainless.decryption_verification.ConsumerOptions;
import org.pgpainless.decryption_verification.DecryptionStream;
public class PgpEncryptorTest {
private static final PGPainless API = PGPainless.getInstance();
private static final OpenPGPKey ALICE = API.generateKey().modernKeyRing("Alice <alice@example.org>");
private static final OpenPGPKey BOB = API.generateKey().modernKeyRing("Bob <bob@example.org>");
private static final OpenPGPKey CAROL = API.generateKey().modernKeyRing("Carol <carol@example.org>");
private final PgpEncryptor encryptor = new PgpEncryptor();
@Test
void everyRecipientCanDecrypt() throws Exception {
byte[] plaintext = "Hello, PGP/MIME\r\n".getBytes(StandardCharsets.UTF_8);
byte[] armored = encryptor.encrypt(plaintext, List.of(ALICE.toCertificate(), BOB.toCertificate()),
List.of(CAROL.toCertificate()));
String text = new String(armored, StandardCharsets.US_ASCII);
assertThat(text).startsWith("-----BEGIN PGP MESSAGE-----").doesNotContain("Version:");
assertThat(decrypt(armored, ALICE)).isEqualTo(plaintext);
assertThat(decrypt(armored, BOB)).isEqualTo(plaintext);
assertThat(decrypt(armored, CAROL)).isEqualTo(plaintext);
}
@Test
void requiresARecipient() {
assertThatIllegalArgumentException().isThrownBy(() -> encryptor.encrypt(new byte[1], List.of(), List.of()));
}
public static byte[] decrypt(byte[] message, OpenPGPKey key) throws Exception {
ByteArrayOutputStream out = new ByteArrayOutputStream();
try (DecryptionStream in = API.processMessage()
.onInputStream(new ByteArrayInputStream(message))
.withOptions(ConsumerOptions.get(API).addDecryptionKey(key))) {
in.transferTo(out);
} catch (IOException e) {
throw e;
}
return out.toByteArray();
}
}
@@ -1,4 +1,4 @@
package io.github.sneakybugmaster.mailsender.pgp.discovery;
package pro.thinhha.mailsender.pgp.discovery;
import static org.assertj.core.api.Assertions.assertThat;
@@ -1,4 +1,4 @@
package io.github.sneakybugmaster.mailsender.pgp.discovery;
package pro.thinhha.mailsender.pgp.discovery;
import static org.assertj.core.api.Assertions.assertThat;
@@ -1,4 +1,4 @@
package io.github.sneakybugmaster.mailsender.pgp.discovery;
package pro.thinhha.mailsender.pgp.discovery;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIOException;
@@ -1,4 +1,4 @@
package io.github.sneakybugmaster.mailsender.pgp.discovery;
package pro.thinhha.mailsender.pgp.discovery;
import java.io.IOException;
import java.io.UncheckedIOException;
@@ -1,4 +1,4 @@
package io.github.sneakybugmaster.mailsender.pgp.discovery;
package pro.thinhha.mailsender.pgp.discovery;
import static org.assertj.core.api.Assertions.assertThat;
@@ -1,4 +1,4 @@
package io.github.sneakybugmaster.mailsender.pgp.discovery;
package pro.thinhha.mailsender.pgp.discovery;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
@@ -0,0 +1,136 @@
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");
}
private static MimeMessage textMessage(String text) throws Exception {
MimeMessage message = new MimeMessage(SESSION);
message.setFrom(new InternetAddress("sender@example.com"));
message.setRecipient(Message.RecipientType.TO, new InternetAddress("alice@example.org"));
message.setRecipient(Message.RecipientType.CC, new InternetAddress("bob@example.org"));
message.setSubject("Quarterly numbers");
if (text != null) {
message.setText(text, "UTF-8");
}
return message;
}
/** Serializes like SMTP would and parses the result like a receiving client. */
private static MimeMessage sendAndReceive(MimeMessage message) throws Exception {
ByteArrayOutputStream out = new ByteArrayOutputStream();
message.writeTo(out);
return new MimeMessage(SESSION, new ByteArrayInputStream(out.toByteArray()));
}
private static String readAll(Part part) throws Exception {
return new String(part.getInputStream().readAllBytes(), StandardCharsets.US_ASCII);
}
private static boolean hasBareLineFeed(byte[] data) {
for (int i = 0; i < data.length; i++) {
if (data[i] == '\n' && (i == 0 || data[i - 1] != '\r')) {
return true;
}
}
return false;
}
}
@@ -0,0 +1,102 @@
package pro.thinhha.mailsender.pgp.validation;
import static org.assertj.core.api.Assertions.assertThat;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.Date;
import java.util.List;
import org.bouncycastle.bcpg.KeyIdentifier;
import org.bouncycastle.openpgp.api.OpenPGPCertificate;
import org.bouncycastle.openpgp.api.OpenPGPKey;
import org.junit.jupiter.api.Test;
import org.pgpainless.PGPainless;
import org.pgpainless.algorithm.EncryptionPurpose;
import org.pgpainless.key.protection.SecretKeyRingProtector;
class CertificateValidatorTest {
private static final String ALICE = "alice@example.org";
private static final SecretKeyRingProtector NO_PASSPHRASE = SecretKeyRingProtector.unprotectedKeys();
private final PGPainless api = PGPainless.getInstance();
private final CertificateValidator validator = new CertificateValidator();
@Test
void acceptsFreshModernKey() {
assertThat(validator.validate(key("Alice <alice@example.org>").toCertificate(), ALICE))
.isInstanceOf(ValidationResult.Valid.class);
}
@Test
void matchesAddressCaseInsensitivelyAndBareUserIds() {
assertThat(validator.validate(key("Alice <Alice@Example.ORG>").toCertificate(), ALICE))
.isInstanceOf(ValidationResult.Valid.class);
assertThat(validator.validate(key("alice@example.org").toCertificate(), ALICE))
.isInstanceOf(ValidationResult.Valid.class);
}
@Test
void rejectsUserIdForAnotherAddress() {
assertRejected(validator.validate(key("Eve <eve@example.org>").toCertificate(), ALICE),
ValidationResult.Reason.USER_ID_MISMATCH);
// a look-alike must not match
assertRejected(validator.validate(key("Alice <alice@example.org.evil>").toCertificate(), ALICE),
ValidationResult.Reason.USER_ID_MISMATCH);
}
@Test
void rejectsRevokedUserId() throws Exception {
OpenPGPKey key = key("Alice <alice@other.example>");
key = api.modify(key).addUserId("Alice <alice@example.org>", NO_PASSPHRASE).done();
assertThat(validator.validate(key.toCertificate(), ALICE)).isInstanceOf(ValidationResult.Valid.class);
key = api.modify(key).revokeUserId("Alice <alice@example.org>", NO_PASSPHRASE).done();
assertRejected(validator.validate(key.toCertificate(), ALICE), ValidationResult.Reason.USER_ID_MISMATCH);
}
@Test
void rejectsRevokedKey() throws Exception {
OpenPGPKey key = api.modify(key("Alice <alice@example.org>")).revoke(NO_PASSPHRASE).done();
assertRejected(validator.validate(key.toCertificate(), ALICE), ValidationResult.Reason.REVOKED);
}
@Test
void rejectsExpiredKey() {
Instant now = Instant.now();
OpenPGPKey key = api.modify(key("Alice <alice@example.org>"))
.setExpirationDate(Date.from(now.plus(Duration.ofHours(1))), NO_PASSPHRASE)
.done();
CertificateValidator later = new CertificateValidator(api,
Clock.fixed(now.plus(Duration.ofHours(2)), ZoneOffset.UTC));
assertThat(validator.validate(key.toCertificate(), ALICE)).isInstanceOf(ValidationResult.Valid.class);
assertRejected(later.validate(key.toCertificate(), ALICE), ValidationResult.Reason.EXPIRED);
}
@Test
void rejectsKeyWhoseEncryptionSubkeyIsRevoked() throws Exception {
OpenPGPKey key = key("Alice <alice@example.org>");
List<KeyIdentifier> encryptionKeys = api.inspect(key).getEncryptionSubkeys(EncryptionPurpose.COMMUNICATIONS)
.stream().map(OpenPGPCertificate.OpenPGPComponentKey::getKeyIdentifier).toList();
for (KeyIdentifier id : encryptionKeys) {
key = api.modify(key).revokeSubKey(id, NO_PASSPHRASE).done();
}
assertRejected(validator.validate(key.toCertificate(), ALICE), ValidationResult.Reason.NO_ENCRYPTION_SUBKEY);
}
private OpenPGPKey key(String userId) {
return api.generateKey().modernKeyRing(userId);
}
private static void assertRejected(ValidationResult result, ValidationResult.Reason reason) {
assertThat(result).isInstanceOfSatisfying(ValidationResult.Rejected.class,
rejected -> assertThat(rejected.reason()).isEqualTo(reason));
}
}
@@ -5,7 +5,7 @@
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>io.github.sneakybugmaster.mailsender</groupId>
<groupId>pro.thinhha.mailsender</groupId>
<artifactId>mailsender-parent</artifactId>
<version>0.1.0-SNAPSHOT</version>
</parent>
@@ -14,7 +14,7 @@
<dependencies>
<dependency>
<groupId>io.github.sneakybugmaster.mailsender</groupId>
<groupId>pro.thinhha.mailsender</groupId>
<artifactId>mailsender-pgp-core</artifactId>
</dependency>
<dependency>
@@ -1,4 +1,4 @@
package io.github.sneakybugmaster.mailsender.pgp.autoconfigure;
package pro.thinhha.mailsender.pgp.autoconfigure;
import java.io.IOException;
import java.io.InputStream;
@@ -18,19 +18,23 @@ import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Primary;
import org.springframework.core.io.Resource;
import io.github.sneakybugmaster.mailsender.pgp.discovery.CachingKeyDiscovery;
import io.github.sneakybugmaster.mailsender.pgp.discovery.Certificates;
import io.github.sneakybugmaster.mailsender.pgp.discovery.CompositeKeyDiscovery;
import io.github.sneakybugmaster.mailsender.pgp.discovery.HostResolver;
import io.github.sneakybugmaster.mailsender.pgp.discovery.HttpWkdFetcher;
import io.github.sneakybugmaster.mailsender.pgp.discovery.KeyDiscovery;
import io.github.sneakybugmaster.mailsender.pgp.discovery.StaticKeyDiscovery;
import io.github.sneakybugmaster.mailsender.pgp.discovery.WkdFetcher;
import io.github.sneakybugmaster.mailsender.pgp.discovery.WkdKeyDiscovery;
import pro.thinhha.mailsender.pgp.crypto.PgpEncryptor;
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.validation.CertificateValidator;
/**
* Key discovery beans: pinned keys first, then WKD behind a cache. The primary {@link KeyDiscovery} bean is the
* combined chain; {@link CachingKeyDiscovery} is exposed separately so applications can evict entries.
* Key discovery (pinned keys first, then WKD behind a cache), certificate validation and PGP/MIME encryption beans.
* The primary {@link KeyDiscovery} bean is the combined chain; {@link CachingKeyDiscovery} is exposed separately so
* applications can evict entries.
*/
@AutoConfiguration
@EnableConfigurationProperties(PgpMailProperties.class)
@@ -75,6 +79,24 @@ public class PgpMailAutoConfiguration {
return new CompositeKeyDiscovery(chain);
}
@Bean
@ConditionalOnMissingBean
CertificateValidator pgpCertificateValidator() {
return new CertificateValidator();
}
@Bean
@ConditionalOnMissingBean
PgpEncryptor pgpEncryptor() {
return new PgpEncryptor();
}
@Bean
@ConditionalOnMissingBean
PgpMimeEncryptor pgpMimeEncryptor(PgpEncryptor encryptor) {
return new PgpMimeEncryptor(encryptor);
}
private static List<OpenPGPCertificate> read(String email, Resource resource) {
try (InputStream in = resource.getInputStream()) {
List<OpenPGPCertificate> certificates = Certificates.parse(in.readAllBytes());
@@ -1,4 +1,4 @@
package io.github.sneakybugmaster.mailsender.pgp.autoconfigure;
package pro.thinhha.mailsender.pgp.autoconfigure;
import java.time.Duration;
import java.util.Map;
@@ -8,7 +8,7 @@ import org.springframework.boot.context.properties.bind.DefaultValue;
import org.springframework.core.io.Resource;
import org.springframework.util.unit.DataSize;
import io.github.sneakybugmaster.mailsender.pgp.discovery.WkdFallback;
import pro.thinhha.mailsender.pgp.discovery.WkdFallback;
/**
* {@code mail.pgp.*} configuration.
@@ -1 +1 @@
io.github.sneakybugmaster.mailsender.pgp.autoconfigure.PgpMailAutoConfiguration
pro.thinhha.mailsender.pgp.autoconfigure.PgpMailAutoConfiguration
@@ -1,4 +1,4 @@
package io.github.sneakybugmaster.mailsender.pgp.autoconfigure;
package pro.thinhha.mailsender.pgp.autoconfigure;
import static org.assertj.core.api.Assertions.assertThat;
@@ -13,12 +13,14 @@ import org.pgpainless.PGPainless;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import io.github.sneakybugmaster.mailsender.pgp.discovery.CachingKeyDiscovery;
import io.github.sneakybugmaster.mailsender.pgp.discovery.DiscoveryResult;
import io.github.sneakybugmaster.mailsender.pgp.discovery.DiscoverySource;
import io.github.sneakybugmaster.mailsender.pgp.discovery.KeyDiscovery;
import io.github.sneakybugmaster.mailsender.pgp.discovery.WkdFallback;
import io.github.sneakybugmaster.mailsender.pgp.discovery.WkdFetcher;
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.validation.CertificateValidator;
class PgpMailAutoConfigurationTest {
@@ -31,7 +33,8 @@ class PgpMailAutoConfigurationTest {
@Test
void createsDiscoveryChainWithDefaults() {
runner.run(context -> {
assertThat(context).hasSingleBean(WkdFetcher.class).hasSingleBean(CachingKeyDiscovery.class);
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);
+2 -2
View File
@@ -5,7 +5,7 @@
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>io.github.sneakybugmaster.mailsender</groupId>
<groupId>pro.thinhha.mailsender</groupId>
<artifactId>mailsender-parent</artifactId>
<version>0.1.0-SNAPSHOT</version>
</parent>
@@ -15,7 +15,7 @@
<dependencies>
<dependency>
<groupId>io.github.sneakybugmaster.mailsender</groupId>
<groupId>pro.thinhha.mailsender</groupId>
<artifactId>mailsender-pgp-spring-boot-autoconfigure</artifactId>
</dependency>
<dependency>
+4 -4
View File
@@ -4,7 +4,7 @@
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>io.github.sneakybugmaster.mailsender</groupId>
<groupId>pro.thinhha.mailsender</groupId>
<artifactId>mailsender-parent</artifactId>
<version>0.1.0-SNAPSHOT</version>
<packaging>pom</packaging>
@@ -40,17 +40,17 @@
<scope>import</scope>
</dependency>
<dependency>
<groupId>io.github.sneakybugmaster.mailsender</groupId>
<groupId>pro.thinhha.mailsender</groupId>
<artifactId>mailsender-pgp-core</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>io.github.sneakybugmaster.mailsender</groupId>
<groupId>pro.thinhha.mailsender</groupId>
<artifactId>mailsender-pgp-spring-boot-autoconfigure</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>io.github.sneakybugmaster.mailsender</groupId>
<groupId>pro.thinhha.mailsender</groupId>
<artifactId>mailsender-pgp-spring-boot-starter</artifactId>
<version>${project.version}</version>
</dependency>