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
@@ -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
-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.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.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;
|
||||
|
||||
+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.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. */
|
||||
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.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.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.
|
||||
+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.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. */
|
||||
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.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.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;
|
||||
|
||||
+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;
|
||||
|
||||
+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.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.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;
|
||||
|
||||
+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.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));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user