mirror of
https://github.com/sneakybugmaster/mailsender.git
synced 2026-11-10 19:48:49 +00:00
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:
44 files changed
+958
-142
No files matched your search
@@ -14,16 +14,17 @@ mvn verify
|
|||||||
|
|
||||||
| Module | Contents |
|
| Module | Contents |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `mailsender-pgp-core` | WKD key discovery, caching, pinned keys (no Spring) |
|
| `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 discovery beans |
|
| `mailsender-pgp-spring-boot-autoconfigure` | `mail.pgp.*` properties and beans |
|
||||||
| `mailsender-pgp-spring-boot-starter` | dependency bundle for applications |
|
| `mailsender-pgp-spring-boot-starter` | dependency bundle for applications |
|
||||||
| `mailsender-demo` | sample app that sends test mails |
|
| `mailsender-demo` | sample app that sends test mails |
|
||||||
|
|
||||||
## Demo app
|
## Demo app
|
||||||
|
|
||||||
The demo app looks up the recipient's key via WKD, logs the result, and sends `DEMO_COUNT` test mails
|
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`). Encryption is not wired in yet, so the mails are sent **in plaintext**,
|
(default: 1 to `test@thinhha.pro`). With a valid key the mail is PGP/MIME-encrypted (RFC 3156); otherwise it is
|
||||||
and the body says whether a key was found.
|
sent in plaintext and a warning is logged. The policy layer (REQUIRE / OPPORTUNISTIC / PLAINTEXT, split
|
||||||
|
delivery) comes in milestone 5.
|
||||||
|
|
||||||
```
|
```
|
||||||
mvn -q install -DskipTests
|
mvn -q install -DskipTests
|
||||||
|
|||||||
+5
-3
@@ -164,12 +164,14 @@ mail.pgp:
|
|||||||
2. **WKD discovery + cache** — `WkdKeyDiscovery`, `HttpWkdFetcher`, `CachingKeyDiscovery`, static/composite discovery,
|
2. **WKD discovery + cache** — `WkdKeyDiscovery`, `HttpWkdFetcher`, `CachingKeyDiscovery`, static/composite discovery,
|
||||||
properties + autoconfig for discovery beans. Tests: z-base-32 vector, advanced/direct fallback matrix,
|
properties + autoconfig for discovery beans. Tests: z-base-32 vector, advanced/direct fallback matrix,
|
||||||
size limit, no redirects, cache TTLs. ✅
|
size limit, no redirects, cache TTLs. ✅
|
||||||
3. **Validation** — generated test keys: expired, revoked, sign-only, wrong UID, valid.
|
3. **Validation** — generated test keys: expired, revoked, revoked UID, revoked encryption subkey, wrong UID, valid. ✅
|
||||||
4. **Encryptor + PGP/MIME** — round-trip decrypt test, golden structure test, CRLF canonicalization.
|
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;
|
5. **Policy + delivery planner** — table-driven tests over key/no-key × REQUIRE/OPPORTUNISTIC/PLAINTEXT × To/Cc/Bcc;
|
||||||
`EnvelopeMimeMessage`.
|
`EnvelopeMimeMessage`.
|
||||||
6. **SecureMailSender (BUILT_IN + RELAY)**, `SendReport`, sender keys, metrics, autoconfig switch.
|
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.
|
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`.
|
`test@thinhha.pro` → found via advanced method, fpr `ADA6EFA0CC1630D602889820112495670FF1B4F0`.
|
||||||
8. **Hardening** — protected headers, actuator endpoint, docs.
|
8. **Hardening** — protected headers, actuator endpoint, docs.
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
<modelVersion>4.0.0</modelVersion>
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
<parent>
|
<parent>
|
||||||
<groupId>io.github.sneakybugmaster.mailsender</groupId>
|
<groupId>pro.thinhha.mailsender</groupId>
|
||||||
<artifactId>mailsender-parent</artifactId>
|
<artifactId>mailsender-parent</artifactId>
|
||||||
<version>0.1.0-SNAPSHOT</version>
|
<version>0.1.0-SNAPSHOT</version>
|
||||||
</parent>
|
</parent>
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
<dependencies>
|
<dependencies>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>io.github.sneakybugmaster.mailsender</groupId>
|
<groupId>pro.thinhha.mailsender</groupId>
|
||||||
<artifactId>mailsender-pgp-spring-boot-starter</artifactId>
|
<artifactId>mailsender-pgp-spring-boot-starter</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
|
|||||||
-76
@@ -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
-1
@@ -1,4 +1,4 @@
|
|||||||
package io.github.sneakybugmaster.mailsender.demo;
|
package pro.thinhha.mailsender.demo;
|
||||||
|
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
+1
-1
@@ -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.ConfigurationProperties;
|
||||||
import org.springframework.boot.context.properties.bind.DefaultValue;
|
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:
|
logging:
|
||||||
level:
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-3
@@ -1,4 +1,4 @@
|
|||||||
package io.github.sneakybugmaster.mailsender.demo;
|
package pro.thinhha.mailsender.demo;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
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
|
// keep the test offline: no real WKD lookup
|
||||||
"mail.pgp.wkd.enabled=false"
|
"mail.pgp.wkd.enabled=false"
|
||||||
})
|
})
|
||||||
class TestMailRunnerTest {
|
class PlaintextTestMailTest {
|
||||||
|
|
||||||
// Started before the Spring context, because the runner sends while the context starts.
|
// Started before the Spring context, because the runner sends while the context starts.
|
||||||
static GreenMail greenMail;
|
static GreenMail greenMail;
|
||||||
@@ -54,6 +54,6 @@ class TestMailRunnerTest {
|
|||||||
assertThat(received).hasSize(2);
|
assertThat(received).hasSize(2);
|
||||||
assertThat(received[0].getAllRecipients()).extracting(Object::toString).containsExactly("test@thinhha.pro");
|
assertThat(received[0].getAllRecipients()).extracting(Object::toString).containsExactly("test@thinhha.pro");
|
||||||
assertThat(received[0].getSubject()).isEqualTo("mailsender test 1/2");
|
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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
<modelVersion>4.0.0</modelVersion>
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
<parent>
|
<parent>
|
||||||
<groupId>io.github.sneakybugmaster.mailsender</groupId>
|
<groupId>pro.thinhha.mailsender</groupId>
|
||||||
<artifactId>mailsender-parent</artifactId>
|
<artifactId>mailsender-parent</artifactId>
|
||||||
<version>0.1.0-SNAPSHOT</version>
|
<version>0.1.0-SNAPSHOT</version>
|
||||||
</parent>
|
</parent>
|
||||||
@@ -30,6 +30,11 @@
|
|||||||
<groupId>org.slf4j</groupId>
|
<groupId>org.slf4j</groupId>
|
||||||
<artifactId>slf4j-api</artifactId>
|
<artifactId>slf4j-api</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<!-- Jakarta Mail API + implementation (same as spring-boot-starter-mail) -->
|
||||||
|
<groupId>org.eclipse.angus</groupId>
|
||||||
|
<artifactId>angus-mail</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.junit.jupiter</groupId>
|
<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
-1
@@ -1,4 +1,4 @@
|
|||||||
package io.github.sneakybugmaster.mailsender.pgp.discovery;
|
package pro.thinhha.mailsender.pgp.discovery;
|
||||||
|
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package io.github.sneakybugmaster.mailsender.pgp.discovery;
|
package pro.thinhha.mailsender.pgp.discovery;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package io.github.sneakybugmaster.mailsender.pgp.discovery;
|
package pro.thinhha.mailsender.pgp.discovery;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package io.github.sneakybugmaster.mailsender.pgp.discovery;
|
package pro.thinhha.mailsender.pgp.discovery;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package io.github.sneakybugmaster.mailsender.pgp.discovery;
|
package pro.thinhha.mailsender.pgp.discovery;
|
||||||
|
|
||||||
/** Where a certificate came from. */
|
/** Where a certificate came from. */
|
||||||
public enum DiscoverySource {
|
public enum DiscoverySource {
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package io.github.sneakybugmaster.mailsender.pgp.discovery;
|
package pro.thinhha.mailsender.pgp.discovery;
|
||||||
|
|
||||||
import java.net.InetAddress;
|
import java.net.InetAddress;
|
||||||
import java.net.UnknownHostException;
|
import java.net.UnknownHostException;
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package io.github.sneakybugmaster.mailsender.pgp.discovery;
|
package pro.thinhha.mailsender.pgp.discovery;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
+1
-1
@@ -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.
|
* Finds the OpenPGP certificate(s) published for an e-mail address.
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package io.github.sneakybugmaster.mailsender.pgp.discovery;
|
package pro.thinhha.mailsender.pgp.discovery;
|
||||||
|
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
+1
-1
@@ -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. */
|
/** When to fall back from the WKD advanced method to the direct method. */
|
||||||
public enum WkdFallback {
|
public enum WkdFallback {
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package io.github.sneakybugmaster.mailsender.pgp.discovery;
|
package pro.thinhha.mailsender.pgp.discovery;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package io.github.sneakybugmaster.mailsender.pgp.discovery;
|
package pro.thinhha.mailsender.pgp.discovery;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
+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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+138
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -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.assertThat;
|
||||||
|
|
||||||
+1
-1
@@ -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.assertThat;
|
||||||
|
|
||||||
+1
-1
@@ -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.assertThat;
|
||||||
import static org.assertj.core.api.Assertions.assertThatIOException;
|
import static org.assertj.core.api.Assertions.assertThatIOException;
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package io.github.sneakybugmaster.mailsender.pgp.discovery;
|
package pro.thinhha.mailsender.pgp.discovery;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.UncheckedIOException;
|
import java.io.UncheckedIOException;
|
||||||
+1
-1
@@ -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.assertThat;
|
||||||
|
|
||||||
+1
-1
@@ -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.assertThat;
|
||||||
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
|
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
|
||||||
+136
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
<modelVersion>4.0.0</modelVersion>
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
<parent>
|
<parent>
|
||||||
<groupId>io.github.sneakybugmaster.mailsender</groupId>
|
<groupId>pro.thinhha.mailsender</groupId>
|
||||||
<artifactId>mailsender-parent</artifactId>
|
<artifactId>mailsender-parent</artifactId>
|
||||||
<version>0.1.0-SNAPSHOT</version>
|
<version>0.1.0-SNAPSHOT</version>
|
||||||
</parent>
|
</parent>
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
<dependencies>
|
<dependencies>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>io.github.sneakybugmaster.mailsender</groupId>
|
<groupId>pro.thinhha.mailsender</groupId>
|
||||||
<artifactId>mailsender-pgp-core</artifactId>
|
<artifactId>mailsender-pgp-core</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
|
|||||||
+34
-12
@@ -1,4 +1,4 @@
|
|||||||
package io.github.sneakybugmaster.mailsender.pgp.autoconfigure;
|
package pro.thinhha.mailsender.pgp.autoconfigure;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
@@ -18,19 +18,23 @@ import org.springframework.context.annotation.Bean;
|
|||||||
import org.springframework.context.annotation.Primary;
|
import org.springframework.context.annotation.Primary;
|
||||||
import org.springframework.core.io.Resource;
|
import org.springframework.core.io.Resource;
|
||||||
|
|
||||||
import io.github.sneakybugmaster.mailsender.pgp.discovery.CachingKeyDiscovery;
|
import pro.thinhha.mailsender.pgp.crypto.PgpEncryptor;
|
||||||
import io.github.sneakybugmaster.mailsender.pgp.discovery.Certificates;
|
import pro.thinhha.mailsender.pgp.discovery.CachingKeyDiscovery;
|
||||||
import io.github.sneakybugmaster.mailsender.pgp.discovery.CompositeKeyDiscovery;
|
import pro.thinhha.mailsender.pgp.discovery.Certificates;
|
||||||
import io.github.sneakybugmaster.mailsender.pgp.discovery.HostResolver;
|
import pro.thinhha.mailsender.pgp.discovery.CompositeKeyDiscovery;
|
||||||
import io.github.sneakybugmaster.mailsender.pgp.discovery.HttpWkdFetcher;
|
import pro.thinhha.mailsender.pgp.discovery.HostResolver;
|
||||||
import io.github.sneakybugmaster.mailsender.pgp.discovery.KeyDiscovery;
|
import pro.thinhha.mailsender.pgp.discovery.HttpWkdFetcher;
|
||||||
import io.github.sneakybugmaster.mailsender.pgp.discovery.StaticKeyDiscovery;
|
import pro.thinhha.mailsender.pgp.discovery.KeyDiscovery;
|
||||||
import io.github.sneakybugmaster.mailsender.pgp.discovery.WkdFetcher;
|
import pro.thinhha.mailsender.pgp.discovery.StaticKeyDiscovery;
|
||||||
import io.github.sneakybugmaster.mailsender.pgp.discovery.WkdKeyDiscovery;
|
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
|
* Key discovery (pinned keys first, then WKD behind a cache), certificate validation and PGP/MIME encryption beans.
|
||||||
* combined chain; {@link CachingKeyDiscovery} is exposed separately so applications can evict entries.
|
* The primary {@link KeyDiscovery} bean is the combined chain; {@link CachingKeyDiscovery} is exposed separately so
|
||||||
|
* applications can evict entries.
|
||||||
*/
|
*/
|
||||||
@AutoConfiguration
|
@AutoConfiguration
|
||||||
@EnableConfigurationProperties(PgpMailProperties.class)
|
@EnableConfigurationProperties(PgpMailProperties.class)
|
||||||
@@ -75,6 +79,24 @@ public class PgpMailAutoConfiguration {
|
|||||||
return new CompositeKeyDiscovery(chain);
|
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) {
|
private static List<OpenPGPCertificate> read(String email, Resource resource) {
|
||||||
try (InputStream in = resource.getInputStream()) {
|
try (InputStream in = resource.getInputStream()) {
|
||||||
List<OpenPGPCertificate> certificates = Certificates.parse(in.readAllBytes());
|
List<OpenPGPCertificate> certificates = Certificates.parse(in.readAllBytes());
|
||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
package io.github.sneakybugmaster.mailsender.pgp.autoconfigure;
|
package pro.thinhha.mailsender.pgp.autoconfigure;
|
||||||
|
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.util.Map;
|
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.core.io.Resource;
|
||||||
import org.springframework.util.unit.DataSize;
|
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.
|
* {@code mail.pgp.*} configuration.
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
io.github.sneakybugmaster.mailsender.pgp.autoconfigure.PgpMailAutoConfiguration
|
pro.thinhha.mailsender.pgp.autoconfigure.PgpMailAutoConfiguration
|
||||||
+11
-8
@@ -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;
|
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.autoconfigure.AutoConfigurations;
|
||||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||||
|
|
||||||
import io.github.sneakybugmaster.mailsender.pgp.discovery.CachingKeyDiscovery;
|
import pro.thinhha.mailsender.pgp.discovery.CachingKeyDiscovery;
|
||||||
import io.github.sneakybugmaster.mailsender.pgp.discovery.DiscoveryResult;
|
import pro.thinhha.mailsender.pgp.discovery.DiscoveryResult;
|
||||||
import io.github.sneakybugmaster.mailsender.pgp.discovery.DiscoverySource;
|
import pro.thinhha.mailsender.pgp.discovery.DiscoverySource;
|
||||||
import io.github.sneakybugmaster.mailsender.pgp.discovery.KeyDiscovery;
|
import pro.thinhha.mailsender.pgp.discovery.KeyDiscovery;
|
||||||
import io.github.sneakybugmaster.mailsender.pgp.discovery.WkdFallback;
|
import pro.thinhha.mailsender.pgp.discovery.WkdFallback;
|
||||||
import io.github.sneakybugmaster.mailsender.pgp.discovery.WkdFetcher;
|
import pro.thinhha.mailsender.pgp.discovery.WkdFetcher;
|
||||||
|
import pro.thinhha.mailsender.pgp.mime.PgpMimeEncryptor;
|
||||||
|
import pro.thinhha.mailsender.pgp.validation.CertificateValidator;
|
||||||
|
|
||||||
class PgpMailAutoConfigurationTest {
|
class PgpMailAutoConfigurationTest {
|
||||||
|
|
||||||
@@ -31,7 +33,8 @@ class PgpMailAutoConfigurationTest {
|
|||||||
@Test
|
@Test
|
||||||
void createsDiscoveryChainWithDefaults() {
|
void createsDiscoveryChainWithDefaults() {
|
||||||
runner.run(context -> {
|
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);
|
assertThat(context.getBean(KeyDiscovery.class)).isNotInstanceOf(CachingKeyDiscovery.class);
|
||||||
|
|
||||||
PgpMailProperties properties = context.getBean(PgpMailProperties.class);
|
PgpMailProperties properties = context.getBean(PgpMailProperties.class);
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
<modelVersion>4.0.0</modelVersion>
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
<parent>
|
<parent>
|
||||||
<groupId>io.github.sneakybugmaster.mailsender</groupId>
|
<groupId>pro.thinhha.mailsender</groupId>
|
||||||
<artifactId>mailsender-parent</artifactId>
|
<artifactId>mailsender-parent</artifactId>
|
||||||
<version>0.1.0-SNAPSHOT</version>
|
<version>0.1.0-SNAPSHOT</version>
|
||||||
</parent>
|
</parent>
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
<dependencies>
|
<dependencies>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>io.github.sneakybugmaster.mailsender</groupId>
|
<groupId>pro.thinhha.mailsender</groupId>
|
||||||
<artifactId>mailsender-pgp-spring-boot-autoconfigure</artifactId>
|
<artifactId>mailsender-pgp-spring-boot-autoconfigure</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
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>
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
<groupId>io.github.sneakybugmaster.mailsender</groupId>
|
<groupId>pro.thinhha.mailsender</groupId>
|
||||||
<artifactId>mailsender-parent</artifactId>
|
<artifactId>mailsender-parent</artifactId>
|
||||||
<version>0.1.0-SNAPSHOT</version>
|
<version>0.1.0-SNAPSHOT</version>
|
||||||
<packaging>pom</packaging>
|
<packaging>pom</packaging>
|
||||||
@@ -40,17 +40,17 @@
|
|||||||
<scope>import</scope>
|
<scope>import</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>io.github.sneakybugmaster.mailsender</groupId>
|
<groupId>pro.thinhha.mailsender</groupId>
|
||||||
<artifactId>mailsender-pgp-core</artifactId>
|
<artifactId>mailsender-pgp-core</artifactId>
|
||||||
<version>${project.version}</version>
|
<version>${project.version}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>io.github.sneakybugmaster.mailsender</groupId>
|
<groupId>pro.thinhha.mailsender</groupId>
|
||||||
<artifactId>mailsender-pgp-spring-boot-autoconfigure</artifactId>
|
<artifactId>mailsender-pgp-spring-boot-autoconfigure</artifactId>
|
||||||
<version>${project.version}</version>
|
<version>${project.version}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>io.github.sneakybugmaster.mailsender</groupId>
|
<groupId>pro.thinhha.mailsender</groupId>
|
||||||
<artifactId>mailsender-pgp-spring-boot-starter</artifactId>
|
<artifactId>mailsender-pgp-spring-boot-starter</artifactId>
|
||||||
<version>${project.version}</version>
|
<version>${project.version}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|||||||
Reference in new issue
Block a user