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 -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));
}
}