From 6f0a5c8707b53f56398447c7ab6d4776ba6c98ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20K=C3=B6rber?= Date: Fri, 6 Jan 2023 14:19:04 +0100 Subject: [PATCH] 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 --- .../acme4j/smime/email/EmailProcessor.java | 205 ++------ .../shredzone/acme4j/smime/wrapper/Mail.java | 84 ++++ .../acme4j/smime/wrapper/SignedMail.java | 469 ++++++++++++++++++ .../smime/wrapper/SignedMailBuilder.java | 303 +++++++++++ .../acme4j/smime/wrapper/SimpleMail.java | 147 ++++++ .../acme4j/smime/wrapper/package-info.java | 23 + .../smime/email/EmailProcessorTest.java | 13 +- 7 files changed, 1062 insertions(+), 182 deletions(-) create mode 100644 acme4j-smime/src/main/java/org/shredzone/acme4j/smime/wrapper/Mail.java create mode 100644 acme4j-smime/src/main/java/org/shredzone/acme4j/smime/wrapper/SignedMail.java create mode 100644 acme4j-smime/src/main/java/org/shredzone/acme4j/smime/wrapper/SignedMailBuilder.java create mode 100644 acme4j-smime/src/main/java/org/shredzone/acme4j/smime/wrapper/SimpleMail.java create mode 100644 acme4j-smime/src/main/java/org/shredzone/acme4j/smime/wrapper/package-info.java diff --git a/acme4j-smime/src/main/java/org/shredzone/acme4j/smime/email/EmailProcessor.java b/acme4j-smime/src/main/java/org/shredzone/acme4j/smime/email/EmailProcessor.java index 67b45366..cb5d6b30 100644 --- a/acme4j-smime/src/main/java/org/shredzone/acme4j/smime/email/EmailProcessor.java +++ b/acme4j-smime/src/main/java/org/shredzone/acme4j/smime/email/EmailProcessor.java @@ -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 messageId; private final InternetAddress sender; private final InternetAddress recipient; + private final @Nullable String messageId; private final Collection replyTo; + private final String token1; private final AtomicReference 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
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" - * must 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 must - * 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 must be rejected. */ - private EmailProcessor(Message message, @Nullable MimeMessage signedMessage, - boolean strict, @Nullable List
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 getMessageId() { - return messageId; + return Optional.ofNullable(messageId); } /** diff --git a/acme4j-smime/src/main/java/org/shredzone/acme4j/smime/wrapper/Mail.java b/acme4j-smime/src/main/java/org/shredzone/acme4j/smime/wrapper/Mail.java new file mode 100644 index 00000000..4ad713a8 --- /dev/null +++ b/acme4j-smime/src/main/java/org/shredzone/acme4j/smime/wrapper/Mail.java @@ -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 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 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; + +} diff --git a/acme4j-smime/src/main/java/org/shredzone/acme4j/smime/wrapper/SignedMail.java b/acme4j-smime/src/main/java/org/shredzone/acme4j/smime/wrapper/SignedMail.java new file mode 100644 index 00000000..70c35528 --- /dev/null +++ b/acme4j-smime/src/main/java/org/shredzone/acme4j/smime/wrapper/SignedMail.java @@ -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}. + *

+ * This class is generated by {@link SignedMailBuilder}, which also takes care for + * signature verification and validation. + * + * @see RFC 7508 + * @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 IGNORE_HEADERS + = unmodifiableSet(new TreeSet<>(asList("CONTENT-TYPE", "MIME-VERSION", "RECEIVED"))); + private static final Set REQUIRED_HEADERS + = unmodifiableSet(new TreeSet<>(asList("FROM", "TO", "SUBJECT"))); + + private final List headers = new ArrayList<>(); + + /** + * This class is to be constructed only by {@link SignedMailBuilder}. + */ + SignedMail() { + // package protected constructor + } + + /** + * Imports untrusted headers from the envelope message. + *

+ * All previously imported headers are cleaned before that. + */ + public void importUntrustedHeaders(Enumeration

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. + *

+ * 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

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. + *

+ * 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

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. + *

+ * 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 getMessageId() { + return headers.stream() + .filter(mh -> "MESSAGE-ID".equalsIgnoreCase(mh.name)) + .map(mh -> mh.value) + .map(String::trim) + .findFirst(); + } + + @Override + public Collection getReplyTo() throws AcmeInvalidMessageException { + List 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 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 getMissingSecuredHeaders() { + Set 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 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; + } + } + +} diff --git a/acme4j-smime/src/main/java/org/shredzone/acme4j/smime/wrapper/SignedMailBuilder.java b/acme4j-smime/src/main/java/org/shredzone/acme4j/smime/wrapper/SignedMailBuilder.java new file mode 100644 index 00000000..92c2c78a --- /dev/null +++ b/acme4j-smime/src/main/java/org/shredzone/acme4j/smime/wrapper/SignedMailBuilder.java @@ -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 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. + *

+ * 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 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 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; + } + +} diff --git a/acme4j-smime/src/main/java/org/shredzone/acme4j/smime/wrapper/SimpleMail.java b/acme4j-smime/src/main/java/org/shredzone/acme4j/smime/wrapper/SimpleMail.java new file mode 100644 index 00000000..bc4a94da --- /dev/null +++ b/acme4j-smime/src/main/java/org/shredzone/acme4j/smime/wrapper/SimpleMail.java @@ -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}. + *

+ * 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 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 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); + } + } + +} diff --git a/acme4j-smime/src/main/java/org/shredzone/acme4j/smime/wrapper/package-info.java b/acme4j-smime/src/main/java/org/shredzone/acme4j/smime/wrapper/package-info.java new file mode 100644 index 00000000..0444fd9e --- /dev/null +++ b/acme4j-smime/src/main/java/org/shredzone/acme4j/smime/wrapper/package-info.java @@ -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; diff --git a/acme4j-smime/src/test/java/org/shredzone/acme4j/smime/email/EmailProcessorTest.java b/acme4j-smime/src/test/java/org/shredzone/acme4j/smime/email/EmailProcessorTest.java index 2caf04e2..68349a82 100644 --- a/acme4j-smime/src/test/java/org/shredzone/acme4j/smime/email/EmailProcessorTest.java +++ b/acme4j-smime/src/test/java/org/shredzone/acme4j/smime/email/EmailProcessorTest.java @@ -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