Enhance validation of S/MIME challenge messages [WIP]

- Fully support S/MIME header protection
- Validation of signature using cacerts, certificates or other
  trust stores
- Improved checks of protected headers

Still missing:

- Unit tests
- EmailProcessor supporting all new features
pull/134/head
Richard Körber 2023-01-06 14:19:04 +01:00
parent 8535bb1698
commit 6f0a5c8707
No known key found for this signature in database
GPG Key ID: AAB9FD19C78AA3E0
7 changed files with 1062 additions and 182 deletions

View File

@ -14,46 +14,31 @@
package org.shredzone.acme4j.smime.email;
import static java.util.Objects.requireNonNull;
import static jakarta.mail.Message.RecipientType.TO;
import java.io.IOException;
import java.net.URL;
import java.security.cert.CertificateParsingException;
import java.security.cert.X509Certificate;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicReference;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import edu.umd.cs.findbugs.annotations.CheckForNull;
import edu.umd.cs.findbugs.annotations.Nullable;
import jakarta.mail.Address;
import jakarta.mail.Message;
import jakarta.mail.MessagingException;
import jakarta.mail.Session;
import jakarta.mail.internet.AddressException;
import jakarta.mail.internet.InternetAddress;
import jakarta.mail.internet.MimeMessage;
import jakarta.mail.internet.MimeMultipart;
import org.bouncycastle.cms.CMSException;
import org.bouncycastle.cms.SignerInformation;
import org.bouncycastle.cms.SignerInformationVerifier;
import org.bouncycastle.cms.jcajce.JcaSimpleSignerInfoVerifierBuilder;
import org.bouncycastle.mail.smime.SMIMESigned;
import org.bouncycastle.operator.OperatorCreationException;
import org.shredzone.acme4j.Identifier;
import org.shredzone.acme4j.Login;
import org.shredzone.acme4j.exception.AcmeProtocolException;
import org.shredzone.acme4j.smime.challenge.EmailReply00Challenge;
import org.shredzone.acme4j.smime.exception.AcmeInvalidMessageException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.shredzone.acme4j.smime.wrapper.Mail;
import org.shredzone.acme4j.smime.wrapper.SignedMail;
import org.shredzone.acme4j.smime.wrapper.SignedMailBuilder;
import org.shredzone.acme4j.smime.wrapper.SimpleMail;
/**
* A processor for incoming "Challenge" emails.
@ -62,16 +47,13 @@ import org.slf4j.LoggerFactory;
* @since 2.12
*/
public final class EmailProcessor {
private static final Logger LOG = LoggerFactory.getLogger(EmailProcessor.class);
private static final Pattern SUBJECT_PATTERN = Pattern.compile("ACME:\\s+([0-9A-Za-z_\\s-]+=?)\\s*");
private static final int RFC822NAME = 1;
private final String token1;
private final Optional<String> messageId;
private final InternetAddress sender;
private final InternetAddress recipient;
private final @Nullable String messageId;
private final Collection<InternetAddress> replyTo;
private final String token1;
private final AtomicReference<EmailReply00Challenge> challengeRef = new AtomicReference<>();
/**
@ -91,7 +73,7 @@ public final class EmailProcessor {
*/
public static EmailProcessor plainMessage(Message message)
throws AcmeInvalidMessageException {
return new EmailProcessor(message, null, false, null);
return new EmailProcessor(new SimpleMail(message));
}
/**
@ -118,64 +100,12 @@ public final class EmailProcessor {
public static EmailProcessor smimeMessage(Message message, Session mailSession,
X509Certificate signCert, boolean strict)
throws AcmeInvalidMessageException {
try {
if (!(message instanceof MimeMessage)) {
throw new AcmeInvalidMessageException("Not a S/MIME message");
}
MimeMessage mimeMessage = (MimeMessage) message;
if (!(mimeMessage.getContent() instanceof MimeMultipart)) {
throw new AcmeProtocolException("S/MIME signed email must contain MimeMultipart");
}
MimeMultipart mp = (MimeMultipart) message.getContent();
SMIMESigned signed = new SMIMESigned(mp);
SignerInformationVerifier verifier = new JcaSimpleSignerInfoVerifierBuilder().build(signCert);
boolean hasMatch = false;
for (SignerInformation signer : signed.getSignerInfos().getSigners()) {
hasMatch |= signer.verify(verifier);
if (hasMatch) {
break;
}
}
if (!hasMatch) {
throw new AcmeInvalidMessageException("The S/MIME signature is invalid");
}
MimeMessage content = signed.getContentAsMimeMessage(mailSession);
if (!"message/rfc822; forwarded=no".equalsIgnoreCase(content.getContentType())) {
throw new AcmeInvalidMessageException("Message does not contain protected headers");
}
MimeMessage body = new MimeMessage(mailSession, content.getInputStream());
List<Address> validFromAddresses = Optional.ofNullable(signCert.getSubjectAlternativeNames())
.orElseGet(Collections::emptyList)
.stream()
.filter(l -> ((Number) l.get(0)).intValue() == RFC822NAME)
.map(l -> l.get(1).toString())
.map(l -> {
try {
return new InternetAddress(l);
} catch (AddressException ex) {
// Ignore invalid email addresses
LOG.debug("Certificate contains invalid e-mail address {}", l, ex);
return null;
}
})
.filter(Objects::nonNull)
.collect(Collectors.toList());
if (validFromAddresses.isEmpty()) {
throw new AcmeInvalidMessageException("Signing certificate does not provide a rfc822Name subjectAltName");
}
return new EmailProcessor(message, body, strict, validFromAddresses);
} catch (IOException | MessagingException | CMSException | OperatorCreationException |
CertificateParsingException ex) {
throw new AcmeInvalidMessageException("Invalid S/MIME mail", ex);
}
SignedMail mail = new SignedMailBuilder()
.withSignCert(signCert)
.relaxed(!strict)
.withMailSession(mailSession)
.build(message);
return new EmailProcessor(mail);
}
/**
@ -185,101 +115,26 @@ public final class EmailProcessor {
*
* @param message
* "Challenge" message as it was sent by the CA.
* @param signedMessage
* The signed part of the challenge message if present, or {@code null}. The
* signature is assumed to be valid, and must be validated in a previous
* step.
* @param strict
* If {@code true}, the S/MIME protected headers "From", "To", and "Subject"
* <em>must</em> match the headers of the received message. If {@code false},
* only the S/MIME protected headers are used, and the headers of the received
* message are ignored.
* @param validFromAddresses
* A {@link List} of {@link Address} that were found in the certificate's
* rfc822Name subjectAltName extension. The mail's From address <em>must</em>
* be found in this list, otherwise the signed message will be rejected.
* {@code null} to disable this validation step.
* @throws AcmeInvalidMessageException
* if a validation failed, and the message <em>must</em> be rejected.
*/
private EmailProcessor(Message message, @Nullable MimeMessage signedMessage,
boolean strict, @Nullable List<Address> validFromAddresses)
throws AcmeInvalidMessageException {
requireNonNull(message, "message");
// Validate challenge and extract token 1
try {
if (!isAutoGenerated(getOptional(m -> m.getHeader("Auto-Submitted"), message, signedMessage))) {
throw new AcmeInvalidMessageException("Message is not auto-generated");
}
Address[] from = getMandatory(Message::getFrom, message, signedMessage, "From");
if (from == null) {
throw new AcmeInvalidMessageException("Message has no 'From' header");
}
if (from.length != 1 || from[0] == null) {
throw new AcmeInvalidMessageException("Message must have exactly one sender, but has " + from.length);
}
if (validFromAddresses != null && !validFromAddresses.contains(from[0])) {
throw new AcmeInvalidMessageException("Sender '" + from[0] + "' was not found in signing certificate");
}
if (strict && signedMessage != null) {
Address[] outerFrom = message.getFrom();
if (outerFrom == null || outerFrom.length != 1 || !from[0].equals(outerFrom[0])) {
throw new AcmeInvalidMessageException("Protected 'From' header does not match envelope header");
}
}
sender = new InternetAddress(from[0].toString());
Address[] to = getMandatory(m -> m.getRecipients(TO), message, signedMessage, "To");
if (to == null) {
throw new AcmeInvalidMessageException("Message has no 'To' header");
}
if (to.length != 1 || to[0] == null) {
throw new AcmeProtocolException("Message must have exactly one recipient, but has " + to.length);
}
if (strict && signedMessage != null) {
Address[] outerTo = message.getRecipients(TO);
if (outerTo == null || outerTo.length != 1 || !to[0].equals(outerTo[0])) {
throw new AcmeInvalidMessageException("Protected 'To' header does not match envelope header");
}
}
recipient = new InternetAddress(to[0].toString());
String subject = getMandatory(Message::getSubject, message, signedMessage, "Subject");
if (subject == null) {
throw new AcmeInvalidMessageException("Message has no 'Subject' header");
}
if (strict && signedMessage != null &&
(message.getSubject() == null || !message.getSubject().equals(signedMessage.getSubject()))) {
throw new AcmeInvalidMessageException("Protected 'Subject' header does not match envelope header");
}
Matcher m = SUBJECT_PATTERN.matcher(subject);
if (!m.matches()) {
throw new AcmeProtocolException("Invalid subject: " + subject);
}
// white spaces within the token part must be ignored
this.token1 = m.group(1).replaceAll("\\s+", "");
Address[] rto = getOptional(Message::getReplyTo, message, signedMessage);
if (rto != null) {
replyTo = Collections.unmodifiableList(Arrays.stream(rto)
.filter(InternetAddress.class::isInstance)
.map(InternetAddress.class::cast)
.collect(Collectors.toList()));
} else {
replyTo = Collections.emptyList();
}
String[] mid = getOptional(n -> n.getHeader("Message-ID"), message, signedMessage);
if (mid != null && mid.length > 0) {
messageId = Optional.of(mid[0]);
} else {
messageId = Optional.empty();
}
} catch (MessagingException ex) {
throw new AcmeProtocolException("Invalid challenge email", ex);
private EmailProcessor(Mail message) throws AcmeInvalidMessageException {
if (!message.isAutoSubmitted()) {
throw new AcmeInvalidMessageException("Message is not auto-generated");
}
String subject = message.getSubject();
Matcher m = SUBJECT_PATTERN.matcher(subject);
if (!m.matches()) {
throw new AcmeProtocolException("Invalid subject: " + subject);
}
// white spaces within the token part must be ignored
this.token1 = m.group(1).replaceAll("\\s+", "");
this.sender = message.getFrom();
this.recipient = message.getTo();
this.messageId = message.getMessageId().orElse(null);
this.replyTo = message.getReplyTo();
}
/**
@ -379,7 +234,7 @@ public final class EmailProcessor {
* Empty if the challenge email has no message-id.
*/
public Optional<String> getMessageId() {
return messageId;
return Optional.ofNullable(messageId);
}
/**

View File

@ -0,0 +1,84 @@
/*
* acme4j - Java ACME client
*
* Copyright (C) 2023 Richard "Shred" Körber
* http://acme4j.shredzone.org
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
*/
package org.shredzone.acme4j.smime.wrapper;
import java.util.Collection;
import java.util.Optional;
import jakarta.mail.internet.InternetAddress;
import org.shredzone.acme4j.smime.exception.AcmeInvalidMessageException;
/**
* Provide access to all required fields of an email. The underlying implementation
* has to take care about parsing, validation, and verification of security features.
*
* @since 2.16
*/
public interface Mail {
/**
* Returns the sender address.
*
* @throws AcmeInvalidMessageException
* if there is not exactly one "From" header, or if the sender address is
* invalid.
*/
InternetAddress getFrom() throws AcmeInvalidMessageException;
/**
* Returns the recipient address.
*
* @throws AcmeInvalidMessageException
* if there is not exactly one "To" header, or if the recipient address is
* invalid.
*/
InternetAddress getTo() throws AcmeInvalidMessageException;
/**
* Returns the subject.
*
* @throws AcmeInvalidMessageException if there is no "Subject" header.
*/
String getSubject() throws AcmeInvalidMessageException;
/**
* Returns a collection of the reply-to addresses. Reply-to addresses that are not
* {@link InternetAddress} type are ignored.
*
* @return Collection of reply-to addresses. May be empty, but is never {@code null}.
* @throws AcmeInvalidMessageException
* if the "Reply-To" header could not be parsed
*/
Collection<InternetAddress> getReplyTo() throws AcmeInvalidMessageException;
/**
* Returns the message ID.
*
* @return Message ID, or empty if there is no message ID header.
* @throws AcmeInvalidMessageException
* if the "Message-ID" header could not be parsed
*/
Optional<String> getMessageId() throws AcmeInvalidMessageException;
/**
* Checks if the mail was flagged as auto-generated.
*
* @return {@code true} if there is an "Auto-Submitted" header containing the string
* "auto-generated", {@code false} otherwise.
* @throws AcmeInvalidMessageException
* if the "Auto-Submitted" header could not be parsed.
*/
boolean isAutoSubmitted() throws AcmeInvalidMessageException;
}

View File

@ -0,0 +1,469 @@
/*
* acme4j - Java ACME client
*
* Copyright (C) 2023 Richard "Shred" Körber
* http://acme4j.shredzone.org
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
*/
package org.shredzone.acme4j.smime.wrapper;
import static java.util.Arrays.asList;
import static java.util.Collections.unmodifiableSet;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Enumeration;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.TreeSet;
import java.util.stream.Collectors;
import edu.umd.cs.findbugs.annotations.Nullable;
import jakarta.mail.Header;
import jakarta.mail.Message;
import jakarta.mail.internet.AddressException;
import jakarta.mail.internet.InternetAddress;
import org.bouncycastle.asn1.ASN1Encodable;
import org.bouncycastle.asn1.ASN1Enumerated;
import org.bouncycastle.asn1.ASN1Integer;
import org.bouncycastle.asn1.ASN1ObjectIdentifier;
import org.bouncycastle.asn1.ASN1Sequence;
import org.bouncycastle.asn1.ASN1Set;
import org.bouncycastle.asn1.ASN1String;
import org.bouncycastle.asn1.cms.Attribute;
import org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers;
import org.bouncycastle.cms.SignerInformation;
import org.shredzone.acme4j.smime.exception.AcmeInvalidMessageException;
/**
* Represents a signed {@link Message}.
* <p>
* This class is generated by {@link SignedMailBuilder}, which also takes care for
* signature verification and validation.
*
* @see <a href="https://www.rfc-editor.org/rfc/rfc7508.html">RFC 7508</a>
* @since 2.16
*/
public class SignedMail implements Mail {
private static final ASN1ObjectIdentifier SECURE_HEADER_FIELDS_ID
= PKCSObjectIdentifiers.pkcs_9.branch("16.2.55");
private static final Set<String> IGNORE_HEADERS
= unmodifiableSet(new TreeSet<>(asList("CONTENT-TYPE", "MIME-VERSION", "RECEIVED")));
private static final Set<String> REQUIRED_HEADERS
= unmodifiableSet(new TreeSet<>(asList("FROM", "TO", "SUBJECT")));
private final List<MailHeader> headers = new ArrayList<>();
/**
* This class is to be constructed only by {@link SignedMailBuilder}.
*/
SignedMail() {
// package protected constructor
}
/**
* Imports untrusted headers from the envelope message.
* <p>
* All previously imported headers are cleaned before that.
*/
public void importUntrustedHeaders(Enumeration<Header> en) {
headers.clear();
while (en.hasMoreElements()) {
Header h = en.nextElement();
String name = h.getName();
if (IGNORE_HEADERS.contains(name.toUpperCase())) {
continue;
}
headers.add(new MailHeader(name, h.getValue()));
}
}
/**
* Imports secured headers from the signed, inner message.
* <p>
* The import is strict. If a secured header is also present in the envelope message,
* it must match exactly.
*
* @throws AcmeInvalidMessageException
* if the secured header was found in the envelope message, but did not match.
*/
public void importTrustedHeaders(Enumeration<Header> en) throws AcmeInvalidMessageException {
while (en.hasMoreElements()) {
Header h = en.nextElement();
String name = h.getName();
if (IGNORE_HEADERS.contains(name.toUpperCase())) {
continue;
}
String value = h.getValue();
long count = headers.stream()
.filter(mh -> mh.nameEquals(name, false) && mh.valueEquals(value, false))
.peek(MailHeader::setTrusted)
.count();
if (count == 0) {
throw new AcmeInvalidMessageException("Secured header '" + name
+ "' does not match envelope header");
}
}
}
/**
* Imports secured headers from the signed, inner message.
* <p>
* The import is relaxed. If the secured header is also found in the envelope message
* header, it will replace the envelope header.
*/
public void importTrustedHeadersRelaxed(Enumeration<Header> en) {
while (en.hasMoreElements()) {
Header h = en.nextElement();
String name = h.getName();
if (IGNORE_HEADERS.contains(name.toUpperCase())) {
continue;
}
headers.removeIf(mh -> mh.nameEquals(name, true) && !mh.trusted);
headers.add(new MailHeader(name, h.getValue()).setTrusted());
}
}
/**
* Imports secured headers from the signature.
* <p>
* Depending on the signature, the envelope header is either checked, deleted, or
* modified.
*
* @throws AcmeInvalidMessageException
* if the signature header conflicts with the envelope header.
*/
public void importSignatureHeaders(SignerInformation si) throws AcmeInvalidMessageException {
Attribute attr = si.getSignedAttributes().get(SECURE_HEADER_FIELDS_ID);
if (attr == null) {
return;
}
boolean relaxed = false;
for (ASN1Encodable element : (ASN1Set) attr.getAttributeValues()[0]) {
if (element instanceof ASN1Enumerated) {
int algorithm = ((ASN1Enumerated) element).intValueExact();
switch (algorithm) {
case 0:
relaxed = false;
break;
case 1:
relaxed = true;
break;
default:
throw new AcmeInvalidMessageException("Unknown algorithm: " + algorithm);
}
}
}
for (ASN1Encodable element : (ASN1Set) attr.getAttributeValues()[0]) {
if (element instanceof ASN1Sequence) {
for (ASN1Encodable sequenceElement : (ASN1Sequence) element) {
ASN1Sequence headerField = (ASN1Sequence) sequenceElement;
String fieldName = ((ASN1String) headerField.getObjectAt(0)).getString();
String fieldValue = ((ASN1String) headerField.getObjectAt(1)).getString();
int fieldStatus = 0;
if (headerField.size() >= 3) {
fieldStatus = ((ASN1Integer) headerField.getObjectAt(2)).intValueExact();
}
switch (fieldStatus) {
case 0:
checkDuplicatedField(fieldName, fieldValue, relaxed);
break;
case 1:
deleteField(fieldName, fieldValue, relaxed);
break;
case 2:
modifyField(fieldName, fieldValue, relaxed);
break;
default:
throw new AcmeInvalidMessageException("Unknown field status " + fieldStatus);
}
}
}
}
}
@Override
public InternetAddress getFrom() throws AcmeInvalidMessageException {
try {
return new InternetAddress(fetchTrustedHeader("FROM"));
} catch (AddressException ex) {
throw new AcmeInvalidMessageException("Invalid 'FROM' address", ex);
}
}
@Override
public InternetAddress getTo() throws AcmeInvalidMessageException {
try {
return new InternetAddress(fetchTrustedHeader("TO"));
} catch (AddressException ex) {
throw new AcmeInvalidMessageException("Invalid 'TO' address", ex);
}
}
@Override
public String getSubject() throws AcmeInvalidMessageException {
return fetchTrustedHeader("SUBJECT");
}
@Override
public Optional<String> getMessageId() {
return headers.stream()
.filter(mh -> "MESSAGE-ID".equalsIgnoreCase(mh.name))
.map(mh -> mh.value)
.map(String::trim)
.findFirst();
}
@Override
public Collection<InternetAddress> getReplyTo() throws AcmeInvalidMessageException {
List<String> replyToList = headers.stream()
.filter(mh -> "REPLY-TO".equalsIgnoreCase(mh.name))
.map(mh -> mh.value)
.map(String::trim)
.collect(Collectors.toList());
if (replyToList.isEmpty()) {
return Collections.emptyList();
}
try {
List<InternetAddress> result = new ArrayList<>(replyToList.size());
for (String replyTo : replyToList) {
result.add(new InternetAddress(replyTo));
}
return Collections.unmodifiableList(result);
} catch (AddressException ex) {
throw new AcmeInvalidMessageException("Invalid 'REPLY-TO' address", ex);
}
}
@Override
public boolean isAutoSubmitted() {
return headers.stream()
.filter(mh -> "AUTO-SUBMITTED".equalsIgnoreCase(mh.name))
.map(mh -> mh.value)
.map(String::trim)
.map(String::toLowerCase)
.anyMatch(h -> h.equals("auto-generated") || h.startsWith("auto-generated;"));
}
/**
* Returns a set of missing, but required secured headers. This list is supposed to
* be empty on valid messages with secured headers. If there is at least one element,
* the message must be refused.
*/
public Set<String> getMissingSecuredHeaders() {
Set<String> missing = new TreeSet<>(REQUIRED_HEADERS);
headers.stream()
.filter(mh -> mh.trusted)
.map(mh -> mh.name)
.map(String::toUpperCase)
.forEach(missing::remove);
return missing;
}
/**
* Processes a "duplicated" header field status. The signature header must be found
* with the same value in the envelope message header.
*
* @param header
* Header name
* @param value
* Expected header value
* @param relaxed
* {@code false}: simple, {@code true}: relaxed algorithm
* @throws AcmeInvalidMessageException
* if a header with the same value was not found
*/
private void checkDuplicatedField(String header, String value, boolean relaxed) throws AcmeInvalidMessageException {
long count = headers.stream()
.filter(mh -> mh.nameEquals(header, relaxed) && mh.valueEquals(value, relaxed))
.peek(MailHeader::setTrusted)
.count();
if (count == 0) {
throw new AcmeInvalidMessageException("Secured header '" + header
+ "' was not found in envelope header");
}
}
/**
* Processes a "deleted" header field status. The signature header must be found
* with the same value in the envelope message header, and is then removed from the
* header.
*
* @param header
* Header name
* @param value
* Expected header value
* @param relaxed
* {@code false}: simple, {@code true}: relaxed algorithm
* @throws AcmeInvalidMessageException
* if a header with the same value was not found
*/
private void deleteField(String header, String value, boolean relaxed) throws AcmeInvalidMessageException {
if (!headers.removeIf(mh -> mh.nameEquals(header, relaxed) && mh.valueEquals(value, relaxed))) {
throw new AcmeInvalidMessageException("Secured header '" + header
+ "' was not found in envelope header for deletion");
}
}
/**
* Processes a "modified" header field status. The signature header must be found in
* the envelope message header, and is then replaced with the given value.
*
* @param header
* Header name
* @param value
* New header value
* @param relaxed
* {@code false}: simple, {@code true}: relaxed algorithm
* @throws AcmeInvalidMessageException
* if the header was not found
*/
private void modifyField(String header, String value, boolean relaxed) throws AcmeInvalidMessageException {
if (!headers.removeIf(mh -> mh.nameEquals(header, relaxed))) {
throw new AcmeInvalidMessageException("Secured header '" + header
+ "' was not found in envelope header for modification");
}
headers.add(new MailHeader(header, value).setTrusted());
}
/**
* Fetches a trusted header. The header must be present exactly once, and must be
* marked as trusted, i.e. it was either found in the signed inner message, or was
* set by the signature headers.
*
* @param name
* Name of the header, case-insensitive
* @return Header value
* @throws AcmeInvalidMessageException
* if the header was not found, was found more than once, or is not marked as
* trusted
*/
private String fetchTrustedHeader(String name) throws AcmeInvalidMessageException {
List<String> candidates = headers.stream()
.filter(mh -> name.equalsIgnoreCase(mh.name))
.filter(mh -> mh.trusted)
.map(mh -> mh.value)
.map(String::trim)
.collect(Collectors.toList());
if (candidates.isEmpty()) {
throw new AcmeInvalidMessageException("Protected '" + name
+ "' header is required, but missing");
}
if (candidates.size() > 1) {
throw new AcmeInvalidMessageException("Expecting exactly one protected '"
+ name + "' header, but found " + candidates.size());
}
return candidates.get(0);
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
for (MailHeader mh : headers) {
sb.append(mh.toString()).append('\n');
}
return sb.toString();
}
/**
* A single mail header.
*/
private static class MailHeader {
public final String name;
public final String value;
public boolean trusted;
/**
* Creates a new mail header.
*
* @param name Header name
* @param value Header value
*/
public MailHeader(String name, String value) {
this.name = name;
this.value = value;
}
/**
* Marks this header as trusted.
*
* @return itself
*/
public MailHeader setTrusted() {
trusted = true;
return this;
}
/**
* Checks if the header name equals the expected value.
*
* @param expected
* Expected name
* @param relaxed
* {@code false}: names must match exactly, {@code true}: case-insensitive
* match
* @return {@code true} if equal
*/
public boolean nameEquals(@Nullable String expected, boolean relaxed) {
if (!relaxed) {
return name.equals(expected);
}
if (expected == null) {
return false;
}
return name.equalsIgnoreCase(expected);
}
/**
* Checks if the header value equals the expected value.
*
* @param expected
* Expected value, may be {@code null}
* @param relaxed
* {@code false}: value must match exactly, {@code true}: differences in
* whitespaces are ignored
* @return {@code true} if equal
*/
public boolean valueEquals(@Nullable String expected, boolean relaxed) {
if (!relaxed) {
return value.equals(expected);
}
if (expected == null) {
return false;
}
String normalizedValue = value.replaceAll("\\s+", " ").trim();
String normalizedExpected = expected.replaceAll("\\s+", " ").trim();
return normalizedValue.equals(normalizedExpected);
}
@Override
public String toString() {
return (trusted ? "* " : " ") + name + ": " + value;
}
}
}

View File

@ -0,0 +1,303 @@
/*
* acme4j - Java ACME client
*
* Copyright (C) 2023 Richard "Shred" Körber
* http://acme4j.shredzone.org
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
*/
package org.shredzone.acme4j.smime.wrapper;
import static java.util.Objects.requireNonNull;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.security.InvalidAlgorithmParameterException;
import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException;
import java.security.cert.CertificateException;
import java.security.cert.PKIXParameters;
import java.security.cert.X509Certificate;
import java.util.Collection;
import java.util.Objects;
import java.util.Properties;
import java.util.Set;
import java.util.concurrent.atomic.AtomicReference;
import edu.umd.cs.findbugs.annotations.Nullable;
import jakarta.mail.Message;
import jakarta.mail.MessagingException;
import jakarta.mail.Session;
import jakarta.mail.internet.AddressException;
import jakarta.mail.internet.InternetAddress;
import jakarta.mail.internet.MimeMessage;
import jakarta.mail.internet.MimeMultipart;
import org.bouncycastle.asn1.x509.Extension;
import org.bouncycastle.asn1.x509.GeneralName;
import org.bouncycastle.asn1.x509.GeneralNames;
import org.bouncycastle.cert.X509CertificateHolder;
import org.bouncycastle.cms.CMSException;
import org.bouncycastle.cms.SignerInformation;
import org.bouncycastle.cms.SignerInformationStore;
import org.bouncycastle.mail.smime.SMIMESigned;
import org.bouncycastle.mail.smime.validator.SignedMailValidator;
import org.bouncycastle.mail.smime.validator.SignedMailValidatorException;
import org.shredzone.acme4j.exception.AcmeProtocolException;
import org.shredzone.acme4j.smime.exception.AcmeInvalidMessageException;
/**
* Creates a {@link SignedMail} instance from a signed message.
*
* @since 2.16
*/
public class SignedMailBuilder {
private static final AtomicReference<KeyStore> CACERTS_TRUSTSTORE = new AtomicReference<>();
private Session mailSession = Session.getDefaultInstance(new Properties());
private boolean relaxed = false;
@Nullable
private PKIXParameters pkixParameters = null;
/**
* Uses the given truststore for certificate validation.
*
* @param trustStore {@link KeyStore} to use.
* @return itself
*/
public SignedMailBuilder withTrustStore(KeyStore trustStore)
throws KeyStoreException, InvalidAlgorithmParameterException {
requireNonNull(trustStore, "trustStore");
return withPKIXParameters(new PKIXParameters(trustStore));
}
/**
* Uses the given {@link X509Certificate} for certificate validation.
*
* @param signCert {@link X509Certificate} to use.
* @return itself
*/
public SignedMailBuilder withSignCert(X509Certificate signCert) {
requireNonNull(signCert, "signCert");
try {
KeyStore ks = KeyStore.getInstance("JKS");
ks.load(null, null);
ks.setCertificateEntry("cert", signCert);
return withTrustStore(ks);
} catch (KeyStoreException | IOException | NoSuchAlgorithmException |
CertificateException | InvalidAlgorithmParameterException ex) {
throw new IllegalArgumentException("Invalid certificate", ex);
}
}
/**
* Uses the given {@link PKIXParameters} for certificate validation.
*
* @param param {@link PKIXParameters} to use.
* @return itself
*/
public SignedMailBuilder withPKIXParameters(PKIXParameters param) {
this.pkixParameters = requireNonNull(param, "param");
return this;
}
/**
* Sets a different mail {@link Session} that is used for accessing the signed
* email body.
*
* @param mailSession {@link Session} to use.
* @return itself
*/
public SignedMailBuilder withMailSession(Session mailSession) {
this.mailSession = requireNonNull(mailSession, "mailSession");
return this;
}
/**
* Changes relaxed validation. If enabled, headers of the signed message body are
* preferred if present, but do not need to match the appropriate headers of the
* envelope message.
* <p>
* By default, relaxed validation is disabled.
*
* @param relaxed sets relaxed validation mode
* @return itself
*/
public SignedMailBuilder relaxed(boolean relaxed) {
this.relaxed = relaxed;
return this;
}
/**
* Validates the message signature and message headers. If validation passes, a
* {@link SignedMail} instance is returned that gives access to the trusted mail
* headers.
*
* @param message {@link Message}, must be a {@link MimeMessage}.
* @return SignedMail containing the trusted headers.
* @throws AcmeInvalidMessageException
* if the given message is invalid, its signature is invalid, or the secured
* headers are invalid. If this exception is thrown, the message MUST be
* rejected.
*/
public SignedMail build(Message message) throws AcmeInvalidMessageException {
requireNonNull(message, "message");
try {
// Check all parameters
if (!(message instanceof MimeMessage)) {
throw new IllegalArgumentException("Message must be a MimeMessage");
}
MimeMessage mimeMessage = (MimeMessage) message;
if (!(mimeMessage.getContent() instanceof MimeMultipart)) {
throw new AcmeProtocolException("S/MIME signed message must contain MimeMultipart");
}
if (pkixParameters == null) {
pkixParameters = new PKIXParameters(getCaCertsTrustStore());
}
// Get the signed message
SMIMESigned signed = new SMIMESigned((MimeMultipart) mimeMessage.getContent());
// Validate the signature
SignerInformation si = validateSignature(mimeMessage, pkixParameters);
// Collect the headers
SignedMail result = new SignedMail();
// First import all untrusted headers from the envelope message
result.importUntrustedHeaders(mimeMessage.getAllHeaders());
// If there is an inner, signed message, import all signed headers
MimeMessage content = signed.getContentAsMimeMessage(mailSession);
if (content != null && content.isMimeType("message/rfc822")) {
MimeMessage protectedBody = new MimeMessage(mailSession, content.getInputStream());
if (relaxed) {
result.importTrustedHeadersRelaxed(protectedBody.getAllHeaders());
} else {
result.importTrustedHeaders(protectedBody.getAllHeaders());
}
}
// Import secured headers from the signature, if present
result.importSignatureHeaders(si);
// Check if all mandatory headers are trusted
Set<String> missing = result.getMissingSecuredHeaders();
if (!missing.isEmpty()) {
throw new AcmeInvalidMessageException("Secured headers expected, but missing: "
+ String.join(", ", missing));
}
// Check if the signer matches the mail sender
InternetAddress signerAddress = validateSigatureSender(signed, si);
if (!result.getFrom().equals(signerAddress)) {
throw new AcmeInvalidMessageException("Message is not signed by the expected sender");
}
return result;
} catch (IOException | MessagingException | CMSException |
KeyStoreException | InvalidAlgorithmParameterException ex) {
throw new AcmeInvalidMessageException("Could not validate message signature", ex);
}
}
/**
* Validates the signature of the signed message.
*
* @return The {@link SignerInformation} of the valid signature.
* @throws AcmeInvalidMessageException
* if the signature is invalid, or if the message was signed with more than
* one signature.
*/
private SignerInformation validateSignature(MimeMessage message, PKIXParameters pkixParameters)
throws AcmeInvalidMessageException {
try {
SignedMailValidator smv = new SignedMailValidator(message, pkixParameters);
SignerInformationStore store = smv.getSignerInformationStore();
if (store.size() != 1) {
throw new AcmeInvalidMessageException("Expected exactly one signer, but found " + store.size());
}
return store.getSigners().iterator().next();
} catch (SignedMailValidatorException ex) {
throw new AcmeInvalidMessageException("Invalid signature", ex);
}
}
/**
* Validates the signature of the sender. It MUST contain a subjectAltName extension
* with a rfc822Name that matches the sender.
*
* @param signed
* {@link SMIMESigned} of the signed message
* @param si
* {@link SignerInformation} of the message signer
* @return The {@link InternetAddress} of the rfc822Name found in the subjectAltName
* @throws AcmeInvalidMessageException
* if no signature was found, or if the signature has no subjectAltName
* extension with rfc822Name.
*/
@SuppressWarnings("unchecked")
private InternetAddress validateSigatureSender(SMIMESigned signed, SignerInformation si)
throws AcmeInvalidMessageException {
Collection<X509CertificateHolder> certCollection = signed.getCertificates().getMatches(si.getSID());
if (certCollection.isEmpty()) {
throw new AcmeInvalidMessageException("Could not find certificate for signer ID "
+ si.getSID().toString());
}
X509CertificateHolder ch = certCollection.iterator().next();
GeneralNames gns = GeneralNames.fromExtensions(ch.getExtensions(), Extension.subjectAlternativeName);
if (gns == null) {
throw new AcmeInvalidMessageException("Certificate does not have a subjectAltName extension");
}
for (GeneralName name : gns.getNames()) {
if (name.getTagNo() == GeneralName.rfc822Name) {
try {
return new InternetAddress(name.getName().toString());
} catch (AddressException ex) {
throw new AcmeInvalidMessageException("Invalid certificate email address: "
+ name.getName().toString(), ex);
}
}
}
throw new AcmeInvalidMessageException("No rfc822Name found in subjectAltName extension");
}
/**
* Generates a truststore from Java's own cacerts file. The result is cached.
*
* @return CaCerts truststore
*/
private static KeyStore getCaCertsTrustStore() {
KeyStore caCerts = CACERTS_TRUSTSTORE.get();
if (caCerts == null) {
String javaHome = System.getProperty("java.home");
String caFileName = javaHome + File.separator + "lib" + File.separator
+ "security" + File.separator + "cacerts";
try (InputStream in = new FileInputStream(caFileName)) {
caCerts = KeyStore.getInstance("JKS");
caCerts.load(in, "changeit".toCharArray());
CACERTS_TRUSTSTORE.set(caCerts);
} catch (KeyStoreException | IOException | CertificateException |
NoSuchAlgorithmException ex) {
throw new IllegalStateException("Cannot access cacerts", ex);
}
}
return caCerts;
}
}

View File

@ -0,0 +1,147 @@
/*
* acme4j - Java ACME client
*
* Copyright (C) 2023 Richard "Shred" Körber
* http://acme4j.shredzone.org
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
*/
package org.shredzone.acme4j.smime.wrapper;
import static java.util.Objects.requireNonNull;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Optional;
import java.util.stream.Collectors;
import jakarta.mail.Address;
import jakarta.mail.Message;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.InternetAddress;
import org.shredzone.acme4j.smime.exception.AcmeInvalidMessageException;
/**
* Represents a simple, unsigned {@link Message}.
* <p>
* There is no signature validation at all. Use this class only for testing purposes,
* or if a validation has already been performed in a separate step.
*
* @since 2.16
*/
public class SimpleMail implements Mail {
private static final String HEADER_MESSAGE_ID = "Message-ID";
private static final String HEADER_AUTO_SUBMITTED = "Auto-Submitted";
private final Message message;
public SimpleMail(Message message) {
this.message = requireNonNull(message, "message");
}
@Override
public InternetAddress getFrom() throws AcmeInvalidMessageException {
try {
Address[] from = message.getFrom();
if (from == null) {
throw new AcmeInvalidMessageException("Missing required 'From' header");
}
if (from.length != 1) {
throw new AcmeInvalidMessageException("Message must have exactly one sender, but has " + from.length);
}
if (!(from[0] instanceof InternetAddress)) {
throw new AcmeInvalidMessageException("Invalid sender message type: " + from[0].getClass().getName());
}
return (InternetAddress) from[0];
} catch (MessagingException ex) {
throw new AcmeInvalidMessageException("Could not read 'From' header", ex);
}
}
@Override
public InternetAddress getTo() throws AcmeInvalidMessageException {
try {
Address[] to = message.getRecipients(Message.RecipientType.TO);
if (to == null) {
throw new AcmeInvalidMessageException("Missing required 'To' header");
}
if (to.length != 1) {
throw new AcmeInvalidMessageException("Message must have exactly one recipient, but has " + to.length);
}
if (!(to[0] instanceof InternetAddress)) {
throw new AcmeInvalidMessageException("Invalid recipient message type: " + to[0].getClass().getName());
}
return (InternetAddress) to[0];
} catch (MessagingException ex) {
throw new AcmeInvalidMessageException("Could not read 'To' header", ex);
}
}
@Override
public String getSubject() throws AcmeInvalidMessageException {
try {
String subject = message.getSubject();
if (subject == null) {
throw new AcmeInvalidMessageException("Message must have a subject");
}
return subject;
} catch (MessagingException ex) {
throw new AcmeInvalidMessageException("Could not read 'Subject' header", ex);
}
}
@Override
public Collection<InternetAddress> getReplyTo() throws AcmeInvalidMessageException {
try {
Address[] rto = message.getReplyTo();
if (rto == null) {
return Collections.emptyList();
}
return Collections.unmodifiableList(Arrays.stream(rto)
.filter(InternetAddress.class::isInstance)
.map(InternetAddress.class::cast)
.collect(Collectors.toList()));
} catch (MessagingException ex) {
throw new AcmeInvalidMessageException("Could not read 'Reply-To' header", ex);
}
}
@Override
public Optional<String> getMessageId() throws AcmeInvalidMessageException {
try {
String[] mid = message.getHeader(HEADER_MESSAGE_ID);
if (mid == null || mid.length == 0) {
return Optional.empty();
}
if (mid.length > 1) {
throw new AcmeInvalidMessageException("Expected one Message-ID, but found " + mid.length);
}
return Optional.of(mid[0]);
} catch (MessagingException ex) {
throw new AcmeInvalidMessageException("Could not read '" + HEADER_MESSAGE_ID + "' header", ex);
}
}
@Override
public boolean isAutoSubmitted() throws AcmeInvalidMessageException {
try {
String[] autoSubmitted = message.getHeader(HEADER_AUTO_SUBMITTED);
if (autoSubmitted == null) {
return false;
}
return Arrays.stream(autoSubmitted)
.map(String::trim)
.map(String::toLowerCase)
.anyMatch(h -> h.equals("auto-generated") || h.startsWith("auto-generated;"));
} catch (MessagingException ex) {
throw new AcmeInvalidMessageException("Could not read '" + HEADER_AUTO_SUBMITTED + "' header", ex);
}
}
}

View File

@ -0,0 +1,23 @@
/*
* acme4j - Java ACME client
*
* Copyright (C) 2023 Richard "Shred" Körber
* http://acme4j.shredzone.org
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
*/
@ReturnValuesAreNonnullByDefault
@DefaultAnnotationForParameters(NonNull.class)
@DefaultAnnotationForFields(NonNull.class)
package org.shredzone.acme4j.smime.wrapper;
import edu.umd.cs.findbugs.annotations.DefaultAnnotationForFields;
import edu.umd.cs.findbugs.annotations.DefaultAnnotationForParameters;
import edu.umd.cs.findbugs.annotations.NonNull;
import edu.umd.cs.findbugs.annotations.ReturnValuesAreNonnullByDefault;

View File

@ -15,7 +15,6 @@ package org.shredzone.acme4j.smime.email;
import static jakarta.mail.Message.RecipientType.TO;
import static org.assertj.core.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.io.IOException;
import java.security.Security;
@ -83,7 +82,7 @@ public class EmailProcessorTest extends SMIMETests {
X509Certificate certificate = readCertificate("valid-signer");
EmailProcessor.smimeMessage(message, mailSession, certificate, true);
})
.withMessage("The S/MIME signature is invalid");
.withMessage("Message is not signed by the expected sender");
}
@Test
@ -94,7 +93,7 @@ public class EmailProcessorTest extends SMIMETests {
X509Certificate certificate = readCertificate("valid-signer-nosan");
EmailProcessor.smimeMessage(message, mailSession, certificate, true);
})
.withMessage("Signing certificate does not provide a rfc822Name subjectAltName");
.withMessage("Certificate does not have a subjectAltName extension");
}
@Test
@ -105,7 +104,7 @@ public class EmailProcessorTest extends SMIMETests {
X509Certificate certificate = readCertificate("valid-signer");
EmailProcessor.smimeMessage(message, mailSession, certificate, true);
})
.withMessage("Sender 'different-ca@example.com' was not found in signing certificate");
.withMessage("Secured header 'From' does not match envelope header");
}
@Test
@ -116,7 +115,7 @@ public class EmailProcessorTest extends SMIMETests {
X509Certificate certificate = readCertificate("valid-signer");
EmailProcessor.smimeMessage(message, mailSession, certificate, true);
})
.withMessage("Protected 'From' header does not match envelope header");
.withMessage("Secured header 'From' does not match envelope header");
}
@Test
@ -127,7 +126,7 @@ public class EmailProcessorTest extends SMIMETests {
X509Certificate certificate = readCertificate("valid-signer");
EmailProcessor.smimeMessage(message, mailSession, certificate, true);
})
.withMessage("Protected 'To' header does not match envelope header");
.withMessage("Secured header 'To' does not match envelope header");
}
@Test
@ -138,7 +137,7 @@ public class EmailProcessorTest extends SMIMETests {
X509Certificate certificate = readCertificate("valid-signer");
EmailProcessor.smimeMessage(message, mailSession, certificate, true);
})
.withMessage("Protected 'Subject' header does not match envelope header");
.withMessage("Secured header 'Subject' does not match envelope header");
}
@Test