All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.shredzone.acme4j.smime.email.EmailProcessor Maven / Gradle / Ivy

There is a newer version: 3.4.0
Show newest version
/*
 * acme4j - Java ACME client
 *
 * Copyright (C) 2021 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.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;

/**
 * A processor for incoming "Challenge" emails.
 *
 * @see RFC 8823
 * @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 Collection replyTo;
    private final AtomicReference challengeRef = new AtomicReference<>();

    /**
     * Processes the given e-mail message.
     * 

* Note that according to RFC-8823, the challenge message must be signed using either * DKIM or S/MIME. This method does not do any DKIM or S/MIME validation, and assumes * that this has already been done by the inbound MTA. * * @param message * E-mail that was received from the CA. The inbound MTA has already taken * care of DKIM and/or S/MIME validation. * @return EmailProcessor for this e-mail * @throws AcmeInvalidMessageException * if a validation failed, and the message must be rejected. * @since 2.15 */ public static EmailProcessor plainMessage(Message message) throws AcmeInvalidMessageException { return new EmailProcessor(message, null, false, null); } /** * Performs an S/MIME validation and processes the given e-mail message. *

* The owner of the given certificate must be the sender of that email. * * @param message * E-mail that was received from the CA. * @param mailSession * A {@link Session} that can be used for processing inner e-mails. * @param signCert * The signing certificate of the sender. * @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. * @return EmailProcessor for this e-mail * @throws AcmeInvalidMessageException * if a validation failed, and the message must be rejected. * @since 2.15 */ 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); } } /** * Creates a new {@link EmailProcessor} for the incoming "Challenge" message. *

* The incoming message is validated against the requirements of RFC-8823. * * @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); } } /** * The expected sender of the "challenge" email. *

* The sender is usually checked when the {@link EmailReply00Challenge} is passed into * the processor, but you can also manually check the sender here. * * @param expectedSender * The expected sender of the "challenge" email. * @return itself * @throws AcmeProtocolException * if the expected sender does not match */ public EmailProcessor expectedFrom(InternetAddress expectedSender) { requireNonNull(expectedSender, "expectedSender"); if (!sender.equals(expectedSender)) { throw new AcmeProtocolException("Message is not sent by the expected sender"); } return this; } /** * The expected recipient of the "challenge" email. *

* This must be the email address of the entity that requested the S/MIME certificate. * The check is not performed by the processor, but should be performed by * the client. * * @param expectedRecipient * The expected recipient of the "challenge" email. * @return itself * @throws AcmeProtocolException * if the expected recipient does not match */ public EmailProcessor expectedTo(InternetAddress expectedRecipient) { requireNonNull(expectedRecipient, "expectedRecipient"); if (!recipient.equals(expectedRecipient)) { throw new AcmeProtocolException("Message is not addressed to expected recipient"); } return this; } /** * The expected identifier. *

* This must be the email address of the entity that requested the S/MIME certificate. * The check is not performed by the processor, but should be performed by * the client. * * @param expectedIdentifier * The expected identifier for the S/MIME certificate. Usually this is an * {@link org.shredzone.acme4j.smime.EmailIdentifier} instance. * @return itself * @throws AcmeProtocolException * if the expected identifier is not an email identifier, or does not match */ public EmailProcessor expectedIdentifier(Identifier expectedIdentifier) { requireNonNull(expectedIdentifier, "expectedIdentifier"); if (!"email".equals(expectedIdentifier.getType())) { throw new AcmeProtocolException("Wrong identifier type: " + expectedIdentifier.getType()); } try { expectedTo(new InternetAddress(expectedIdentifier.getValue())); } catch (MessagingException ex) { throw new AcmeProtocolException("Invalid email address", ex); } return this; } /** * Returns the sender of the "challenge" email. */ public InternetAddress getSender() { return sender; } /** * Returns the recipient of the "challenge" email. */ public InternetAddress getRecipient() { return recipient; } /** * Returns all "reply-to" email addresses found in the "challenge" email. *

* Empty if there was no reply-to header, but never {@code null}. */ public Collection getReplyTo() { return replyTo; } /** * Returns the message-id of the "challenge" email. *

* Empty if the challenge email has no message-id. */ public Optional getMessageId() { return messageId; } /** * Returns the "token 1" found in the subject of the "challenge" email. */ public String getToken1() { return token1; } /** * Sets the corresponding {@link EmailReply00Challenge} that was received from the CA * for validation. * * @param challenge * {@link EmailReply00Challenge} that corresponds to this email * @return itself * @throws AcmeProtocolException * if the challenge does not match this "challenge" email. */ public EmailProcessor withChallenge(EmailReply00Challenge challenge) { requireNonNull(challenge, "challenge"); expectedFrom(challenge.getExpectedSender()); if (challengeRef.get() != null) { throw new IllegalStateException("A challenge has already been set"); } challengeRef.set(challenge); return this; } /** * Sets the corresponding {@link EmailReply00Challenge} that was received from the CA * for validation. *

* This is a convenience call in case that only the challenge location URL is * available. * * @param login * A valid {@link Login} * @param challengeLocation * The location URL of the corresponding challenge. * @return itself * @throws AcmeProtocolException * if the challenge does not match this "challenge" email. */ public EmailProcessor withChallenge(Login login, URL challengeLocation) { return withChallenge(login.bindChallenge(challengeLocation, EmailReply00Challenge.class)); } /** * Returns the full token of this challenge. *

* The corresponding email-reply-00 challenge must be set before. */ public String getToken() { checkChallengePresent(); return challengeRef.get().getToken(getToken1()); } /** * Returns the key-authorization of this challenge. This is the response to be used in * the response email. *

* The corresponding email-reply-00 challenge must be set before. */ public String getAuthorization() { checkChallengePresent(); return challengeRef.get().getAuthorization(getToken1()); } /** * Returns a {@link ResponseGenerator} for generating a response email. *

* The corresponding email-reply-00 challenge must be set before. */ public ResponseGenerator respond() { checkChallengePresent(); return new ResponseGenerator(this); } /** * Get an optional property from the message. *

* Optional property means: If there is a signed message, try to fetch the property * from there. If the property is not present, fetch it from the unsigned message * instead. If it's also not there, return {@code null}. * * @param getter * The getter method of {@link Message} to be invoked * @param message * The outer (unsigned) {@link Message} that serves as fallback * @param signedMessage * The signed (inner) {@link Message} where the property is looked up first * @param * The expected result type * @return The mail property, or {@code null} if not found */ @CheckForNull private T getOptional(MessageFunction getter, Message message, @Nullable Message signedMessage) throws MessagingException { if (signedMessage != null) { T result = getter.apply(signedMessage); if (result != null) { return result; } } return getter.apply(message); } /** * Get a mandatory property from the message. *

* Mandatory means: If there is a signed message, the property must be * present there. The unsigned message is only queried as fallback if there is no * signed message at all. * * @param getter * The getter method of {@link Message} to be invoked * @param message * The outer (unsigned) {@link Message} that serves as fallback if there is * no signed message. * @param signedMessage * The signed (inner) {@link Message} where the property is expected, or * {@code null} if there is no signed message. * @param header * Name of the expected header * @param * The expected result type * @return The mail property, or {@code null} if not found */ @CheckForNull private T getMandatory(MessageFunction getter, Message message, @Nullable Message signedMessage, String header) throws MessagingException, AcmeInvalidMessageException { if (signedMessage != null) { T value = getter.apply(signedMessage); if (value == null) { throw new AcmeInvalidMessageException("Protected header '" + header + "' expected, but missing."); } return value; } return getter.apply(message); } /** * Checks if this message is "auto-generated". * * @param autoSubmitted * Auto-Submitted header content * @return {@code true} if the mail was auto-generated. */ private boolean isAutoGenerated(@Nullable String[] autoSubmitted) throws MessagingException { if (autoSubmitted == null || autoSubmitted.length == 0) { return false; } return Arrays.stream(autoSubmitted) .map(String::trim) .anyMatch(h -> h.startsWith("auto-generated")); } /** * Checks if a challenge has been set. Throws an exception if not. */ private void checkChallengePresent() { if (challengeRef.get() == null) { throw new IllegalStateException("No challenge has been set yet"); } } @FunctionalInterface private interface MessageFunction { @CheckForNull R apply(M message) throws MessagingException; } }





© 2015 - 2024 Weber Informatics LLC | Privacy Policy