001/*
002 * acme4j - Java ACME client
003 *
004 * Copyright (C) 2021 Richard "Shred" Körber
005 *   http://acme4j.shredzone.org
006 *
007 * Licensed under the Apache License, Version 2.0 (the "License");
008 * you may not use this file except in compliance with the License.
009 *
010 * This program is distributed in the hope that it will be useful,
011 * but WITHOUT ANY WARRANTY; without even the implied warranty of
012 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
013 */
014package org.shredzone.acme4j.smime.email;
015
016import static java.util.Objects.requireNonNull;
017import static jakarta.mail.Message.RecipientType.TO;
018
019import java.io.IOException;
020import java.net.URL;
021import java.security.cert.CertificateParsingException;
022import java.security.cert.X509Certificate;
023import java.util.Arrays;
024import java.util.Collection;
025import java.util.Collections;
026import java.util.List;
027import java.util.Objects;
028import java.util.Optional;
029import java.util.concurrent.atomic.AtomicReference;
030import java.util.regex.Matcher;
031import java.util.regex.Pattern;
032import java.util.stream.Collectors;
033
034import edu.umd.cs.findbugs.annotations.CheckForNull;
035import edu.umd.cs.findbugs.annotations.Nullable;
036import jakarta.mail.Address;
037import jakarta.mail.Message;
038import jakarta.mail.MessagingException;
039import jakarta.mail.Session;
040import jakarta.mail.internet.AddressException;
041import jakarta.mail.internet.InternetAddress;
042import jakarta.mail.internet.MimeMessage;
043import jakarta.mail.internet.MimeMultipart;
044import org.bouncycastle.cms.CMSException;
045import org.bouncycastle.cms.SignerInformation;
046import org.bouncycastle.cms.SignerInformationVerifier;
047import org.bouncycastle.cms.jcajce.JcaSimpleSignerInfoVerifierBuilder;
048import org.bouncycastle.mail.smime.SMIMESigned;
049import org.bouncycastle.operator.OperatorCreationException;
050import org.shredzone.acme4j.Identifier;
051import org.shredzone.acme4j.Login;
052import org.shredzone.acme4j.exception.AcmeProtocolException;
053import org.shredzone.acme4j.smime.challenge.EmailReply00Challenge;
054import org.shredzone.acme4j.smime.exception.AcmeInvalidMessageException;
055import org.slf4j.Logger;
056import org.slf4j.LoggerFactory;
057
058/**
059 * A processor for incoming "Challenge" emails.
060 *
061 * @see <a href="https://datatracker.ietf.org/doc/html/rfc8823">RFC 8823</a>
062 * @since 2.12
063 */
064public final class EmailProcessor {
065
066    private static final Logger LOG = LoggerFactory.getLogger(EmailProcessor.class);
067    private static final Pattern SUBJECT_PATTERN = Pattern.compile("ACME:\\s+([0-9A-Za-z_\\s-]+=?)\\s*");
068    private static final int RFC822NAME = 1;
069
070    private final String token1;
071    private final Optional<String> messageId;
072    private final InternetAddress sender;
073    private final InternetAddress recipient;
074    private final Collection<InternetAddress> replyTo;
075    private final AtomicReference<EmailReply00Challenge> challengeRef = new AtomicReference<>();
076
077    /**
078     * Processes the given e-mail message.
079     * <p>
080     * Note that according to RFC-8823, the challenge message must be signed using either
081     * DKIM or S/MIME. This method does not do any DKIM or S/MIME validation, and assumes
082     * that this has already been done by the inbound MTA.
083     *
084     * @param message
085     *         E-mail that was received from the CA. The inbound MTA has already taken
086     *         care of DKIM and/or S/MIME validation.
087     * @return EmailProcessor for this e-mail
088     * @throws AcmeInvalidMessageException
089     *         if a validation failed, and the message <em>must</em> be rejected.
090     * @since 2.15
091     */
092    public static EmailProcessor plainMessage(Message message)
093            throws AcmeInvalidMessageException {
094        return new EmailProcessor(message, null, false, null);
095    }
096
097    /**
098     * Performs an S/MIME validation and processes the given e-mail message.
099     * <p>
100     * The owner of the given certificate must be the sender of that email.
101     *
102     * @param message
103     *         E-mail that was received from the CA.
104     * @param mailSession
105     *         A {@link Session} that can be used for processing inner e-mails.
106     * @param signCert
107     *         The signing certificate of the sender.
108     * @param strict
109     *         If {@code true}, the S/MIME protected headers "From", "To", and "Subject"
110     *         <em>must</em> match the headers of the received message. If {@code false},
111     *         only the S/MIME protected headers are used, and the headers of the received
112     *         message are ignored.
113     * @return EmailProcessor for this e-mail
114     * @throws AcmeInvalidMessageException
115     *         if a validation failed, and the message <em>must</em> be rejected.
116     * @since 2.15
117     */
118    public static EmailProcessor smimeMessage(Message message, Session mailSession,
119                                              X509Certificate signCert, boolean strict)
120            throws AcmeInvalidMessageException {
121        try {
122            if (!(message instanceof MimeMessage)) {
123                throw new AcmeInvalidMessageException("Not a S/MIME message");
124            }
125            MimeMessage mimeMessage = (MimeMessage) message;
126
127            if (!(mimeMessage.getContent() instanceof MimeMultipart)) {
128                throw new AcmeProtocolException("S/MIME signed email must contain MimeMultipart");
129            }
130            MimeMultipart mp = (MimeMultipart) message.getContent();
131
132            SMIMESigned signed = new SMIMESigned(mp);
133
134            SignerInformationVerifier verifier = new JcaSimpleSignerInfoVerifierBuilder().build(signCert);
135            boolean hasMatch = false;
136            for (SignerInformation signer : signed.getSignerInfos().getSigners()) {
137                hasMatch |= signer.verify(verifier);
138                if (hasMatch) {
139                    break;
140                }
141            }
142            if (!hasMatch) {
143                throw new AcmeInvalidMessageException("The S/MIME signature is invalid");
144            }
145
146            MimeMessage content = signed.getContentAsMimeMessage(mailSession);
147            if (!"message/rfc822; forwarded=no".equalsIgnoreCase(content.getContentType())) {
148                throw new AcmeInvalidMessageException("Message does not contain protected headers");
149            }
150
151            MimeMessage body = new MimeMessage(mailSession, content.getInputStream());
152
153            List<Address> validFromAddresses = Optional.ofNullable(signCert.getSubjectAlternativeNames())
154                    .orElseGet(Collections::emptyList)
155                    .stream()
156                    .filter(l -> ((Number) l.get(0)).intValue() == RFC822NAME)
157                    .map(l -> l.get(1).toString())
158                    .map(l -> {
159                        try {
160                            return new InternetAddress(l);
161                        } catch (AddressException ex) {
162                            // Ignore invalid email addresses
163                            LOG.debug("Certificate contains invalid e-mail address {}", l, ex);
164                            return null;
165                        }
166                    })
167                    .filter(Objects::nonNull)
168                    .collect(Collectors.toList());
169
170            if (validFromAddresses.isEmpty()) {
171                throw new AcmeInvalidMessageException("Signing certificate does not provide a rfc822Name subjectAltName");
172            }
173
174            return new EmailProcessor(message, body, strict, validFromAddresses);
175        } catch (IOException | MessagingException | CMSException | OperatorCreationException |
176                 CertificateParsingException ex) {
177            throw new AcmeInvalidMessageException("Invalid S/MIME mail", ex);
178        }
179    }
180
181    /**
182     * Creates a new {@link EmailProcessor} for the incoming "Challenge" message.
183     * <p>
184     * The incoming message is validated against the requirements of RFC-8823.
185     *
186     * @param message
187     *         "Challenge" message as it was sent by the CA.
188     * @param signedMessage
189     *         The signed part of the challenge message if present, or {@code null}. The
190     *         signature is assumed to be valid, and must be validated in a previous
191     *         step.
192     * @param strict
193     *         If {@code true}, the S/MIME protected headers "From", "To", and "Subject"
194     *         <em>must</em> match the headers of the received message. If {@code false},
195     *         only the S/MIME protected headers are used, and the headers of the received
196     *         message are ignored.
197     * @param validFromAddresses
198     *         A {@link List} of {@link Address} that were found in the certificate's
199     *         rfc822Name subjectAltName extension. The mail's From address <em>must</em>
200     *         be found in this list, otherwise the signed message will be rejected.
201     *         {@code null} to disable this validation step.
202     * @throws AcmeInvalidMessageException
203     *         if a validation failed, and the message <em>must</em> be rejected.
204     */
205    private EmailProcessor(Message message, @Nullable MimeMessage signedMessage,
206                           boolean strict, @Nullable List<Address> validFromAddresses)
207            throws AcmeInvalidMessageException {
208        requireNonNull(message, "message");
209
210        // Validate challenge and extract token 1
211        try {
212            if (!isAutoGenerated(getOptional(m -> m.getHeader("Auto-Submitted"), message, signedMessage))) {
213                throw new AcmeInvalidMessageException("Message is not auto-generated");
214            }
215
216            Address[] from = getMandatory(Message::getFrom, message, signedMessage, "From");
217            if (from == null) {
218                throw new AcmeInvalidMessageException("Message has no 'From' header");
219            }
220            if (from.length != 1 || from[0] == null) {
221                throw new AcmeInvalidMessageException("Message must have exactly one sender, but has " + from.length);
222            }
223            if (validFromAddresses != null && !validFromAddresses.contains(from[0])) {
224                throw new AcmeInvalidMessageException("Sender '" + from[0] + "' was not found in signing certificate");
225            }
226            if (strict && signedMessage != null) {
227                Address[] outerFrom = message.getFrom();
228                if (outerFrom == null || outerFrom.length != 1 || !from[0].equals(outerFrom[0])) {
229                    throw new AcmeInvalidMessageException("Protected 'From' header does not match envelope header");
230                }
231            }
232            sender = new InternetAddress(from[0].toString());
233
234            Address[] to = getMandatory(m -> m.getRecipients(TO), message, signedMessage, "To");
235            if (to == null) {
236                throw new AcmeInvalidMessageException("Message has no 'To' header");
237            }
238            if (to.length != 1 || to[0] == null) {
239                throw new AcmeProtocolException("Message must have exactly one recipient, but has " + to.length);
240            }
241            if (strict && signedMessage != null) {
242                Address[] outerTo = message.getRecipients(TO);
243                if (outerTo == null || outerTo.length != 1 || !to[0].equals(outerTo[0])) {
244                    throw new AcmeInvalidMessageException("Protected 'To' header does not match envelope header");
245                }
246            }
247            recipient = new InternetAddress(to[0].toString());
248
249            String subject = getMandatory(Message::getSubject, message, signedMessage, "Subject");
250            if (subject == null) {
251                throw new AcmeInvalidMessageException("Message has no 'Subject' header");
252            }
253            if (strict && signedMessage != null &&
254                    (message.getSubject() == null || !message.getSubject().equals(signedMessage.getSubject()))) {
255                throw new AcmeInvalidMessageException("Protected 'Subject' header does not match envelope header");
256            }
257            Matcher m = SUBJECT_PATTERN.matcher(subject);
258            if (!m.matches()) {
259                throw new AcmeProtocolException("Invalid subject: " + subject);
260            }
261            // white spaces within the token part must be ignored
262            this.token1 = m.group(1).replaceAll("\\s+", "");
263
264            Address[] rto = getOptional(Message::getReplyTo, message, signedMessage);
265            if (rto != null) {
266                replyTo = Collections.unmodifiableList(Arrays.stream(rto)
267                        .filter(InternetAddress.class::isInstance)
268                        .map(InternetAddress.class::cast)
269                        .collect(Collectors.toList()));
270            } else {
271                replyTo = Collections.emptyList();
272            }
273
274            String[] mid = getOptional(n -> n.getHeader("Message-ID"), message, signedMessage);
275            if (mid != null && mid.length > 0) {
276                messageId = Optional.of(mid[0]);
277            } else {
278                messageId = Optional.empty();
279            }
280        } catch (MessagingException ex) {
281            throw new AcmeProtocolException("Invalid challenge email", ex);
282        }
283    }
284
285    /**
286     * The expected sender of the "challenge" email.
287     * <p>
288     * The sender is usually checked when the {@link EmailReply00Challenge} is passed into
289     * the processor, but you can also manually check the sender here.
290     *
291     * @param expectedSender
292     *         The expected sender of the "challenge" email.
293     * @return itself
294     * @throws AcmeProtocolException
295     *         if the expected sender does not match
296     */
297    public EmailProcessor expectedFrom(InternetAddress expectedSender) {
298        requireNonNull(expectedSender, "expectedSender");
299        if (!sender.equals(expectedSender)) {
300            throw new AcmeProtocolException("Message is not sent by the expected sender");
301        }
302        return this;
303    }
304
305    /**
306     * The expected recipient of the "challenge" email.
307     * <p>
308     * This must be the email address of the entity that requested the S/MIME certificate.
309     * The check is not performed by the processor, but <em>should</em> be performed by
310     * the client.
311     *
312     * @param expectedRecipient
313     *         The expected recipient of the "challenge" email.
314     * @return itself
315     * @throws AcmeProtocolException
316     *         if the expected recipient does not match
317     */
318    public EmailProcessor expectedTo(InternetAddress expectedRecipient) {
319        requireNonNull(expectedRecipient, "expectedRecipient");
320        if (!recipient.equals(expectedRecipient)) {
321            throw new AcmeProtocolException("Message is not addressed to expected recipient");
322        }
323        return this;
324    }
325
326    /**
327     * The expected identifier.
328     * <p>
329     * This must be the email address of the entity that requested the S/MIME certificate.
330     * The check is not performed by the processor, but <em>should</em> be performed by
331     * the client.
332     *
333     * @param expectedIdentifier
334     *         The expected identifier for the S/MIME certificate. Usually this is an
335     *         {@link org.shredzone.acme4j.smime.EmailIdentifier} instance.
336     * @return itself
337     * @throws AcmeProtocolException
338     *         if the expected identifier is not an email identifier, or does not match
339     */
340    public EmailProcessor expectedIdentifier(Identifier expectedIdentifier) {
341        requireNonNull(expectedIdentifier, "expectedIdentifier");
342        if (!"email".equals(expectedIdentifier.getType())) {
343            throw new AcmeProtocolException("Wrong identifier type: " + expectedIdentifier.getType());
344        }
345        try {
346            expectedTo(new InternetAddress(expectedIdentifier.getValue()));
347        } catch (MessagingException ex) {
348            throw new AcmeProtocolException("Invalid email address", ex);
349        }
350        return this;
351    }
352
353    /**
354     * Returns the sender of the "challenge" email.
355     */
356    public InternetAddress getSender() {
357        return sender;
358    }
359
360    /**
361     * Returns the recipient of the "challenge" email.
362     */
363    public InternetAddress getRecipient() {
364        return recipient;
365    }
366
367    /**
368     * Returns all "reply-to" email addresses found in the "challenge" email.
369     * <p>
370     * Empty if there was no reply-to header, but never {@code null}.
371     */
372    public Collection<InternetAddress> getReplyTo() {
373        return replyTo;
374    }
375
376    /**
377     * Returns the message-id of the "challenge" email.
378     * <p>
379     * Empty if the challenge email has no message-id.
380     */
381    public Optional<String> getMessageId() {
382        return messageId;
383    }
384
385    /**
386     * Returns the "token 1" found in the subject of the "challenge" email.
387     */
388    public String getToken1() {
389        return token1;
390    }
391
392    /**
393     * Sets the corresponding {@link EmailReply00Challenge} that was received from the CA
394     * for validation.
395     *
396     * @param challenge
397     *         {@link EmailReply00Challenge} that corresponds to this email
398     * @return itself
399     * @throws AcmeProtocolException
400     *         if the challenge does not match this "challenge" email.
401     */
402    public EmailProcessor withChallenge(EmailReply00Challenge challenge) {
403        requireNonNull(challenge, "challenge");
404        expectedFrom(challenge.getExpectedSender());
405        if (challengeRef.get() != null) {
406            throw new IllegalStateException("A challenge has already been set");
407        }
408        challengeRef.set(challenge);
409        return this;
410    }
411
412    /**
413     * Sets the corresponding {@link EmailReply00Challenge} that was received from the CA
414     * for validation.
415     * <p>
416     * This is a convenience call in case that only the challenge location URL is
417     * available.
418     *
419     * @param login
420     *         A valid {@link Login}
421     * @param challengeLocation
422     *         The location URL of the corresponding challenge.
423     * @return itself
424     * @throws AcmeProtocolException
425     *         if the challenge does not match this "challenge" email.
426     */
427    public EmailProcessor withChallenge(Login login, URL challengeLocation) {
428        return withChallenge(login.bindChallenge(challengeLocation, EmailReply00Challenge.class));
429    }
430
431    /**
432     * Returns the full token of this challenge.
433     * <p>
434     * The corresponding email-reply-00 challenge must be set before.
435     */
436    public String getToken() {
437        checkChallengePresent();
438        return challengeRef.get().getToken(getToken1());
439    }
440
441    /**
442     * Returns the key-authorization of this challenge. This is the response to be used in
443     * the response email.
444     * <p>
445     * The corresponding email-reply-00 challenge must be set before.
446     */
447    public String getAuthorization() {
448        checkChallengePresent();
449        return challengeRef.get().getAuthorization(getToken1());
450    }
451
452    /**
453     * Returns a {@link ResponseGenerator} for generating a response email.
454     * <p>
455     * The corresponding email-reply-00 challenge must be set before.
456     */
457    public ResponseGenerator respond() {
458        checkChallengePresent();
459        return new ResponseGenerator(this);
460    }
461
462    /**
463     * Get an optional property from the message.
464     * <p>
465     * Optional property means: If there is a signed message, try to fetch the property
466     * from there. If the property is not present, fetch it from the unsigned message
467     * instead. If it's also not there, return {@code null}.
468     *
469     * @param getter
470     *         The getter method of {@link Message} to be invoked
471     * @param message
472     *         The outer (unsigned) {@link Message} that serves as fallback
473     * @param signedMessage
474     *         The signed (inner) {@link Message} where the property is looked up first
475     * @param <T>
476     *         The expected result type
477     * @return The mail property, or {@code null} if not found
478     */
479    @CheckForNull
480    private <T> T getOptional(MessageFunction<Message, T> getter, Message message, @Nullable Message signedMessage)
481            throws MessagingException {
482        if (signedMessage != null) {
483            T result = getter.apply(signedMessage);
484            if (result != null) {
485                return result;
486            }
487        }
488        return getter.apply(message);
489    }
490
491    /**
492     * Get a mandatory property from the message.
493     * <p>
494     * Mandatory means: If there is a signed message, the property <em>must</em> be
495     * present there. The unsigned message is only queried as fallback if there is no
496     * signed message at all.
497     *
498     * @param getter
499     *         The getter method of {@link Message} to be invoked
500     * @param message
501     *         The outer (unsigned) {@link Message} that serves as fallback if there is
502     *         no signed message.
503     * @param signedMessage
504     *         The signed (inner) {@link Message} where the property is expected, or
505     *         {@code null} if there is no signed message.
506     * @param header
507     *         Name of the expected header
508     * @param <T>
509     *         The expected result type
510     * @return The mail property, or {@code null} if not found
511     */
512    @CheckForNull
513    private <T> T getMandatory(MessageFunction<Message, T> getter, Message message, @Nullable Message signedMessage, String header)
514            throws MessagingException, AcmeInvalidMessageException {
515        if (signedMessage != null) {
516            T value = getter.apply(signedMessage);
517            if (value == null) {
518                throw new AcmeInvalidMessageException("Protected header '" + header + "' expected, but missing.");
519            }
520            return value;
521        }
522        return getter.apply(message);
523    }
524
525    /**
526     * Checks if this message is "auto-generated".
527     *
528     * @param autoSubmitted
529     *         Auto-Submitted header content
530     * @return {@code true} if the mail was auto-generated.
531     */
532    private boolean isAutoGenerated(@Nullable String[] autoSubmitted) throws MessagingException {
533        if (autoSubmitted == null || autoSubmitted.length == 0) {
534            return false;
535        }
536        return Arrays.stream(autoSubmitted)
537                .map(String::trim)
538                .anyMatch(h -> h.startsWith("auto-generated"));
539    }
540
541    /**
542     * Checks if a challenge has been set. Throws an exception if not.
543     */
544    private void checkChallengePresent() {
545        if (challengeRef.get() == null) {
546            throw new IllegalStateException("No challenge has been set yet");
547        }
548    }
549
550    @FunctionalInterface
551    private interface MessageFunction<M extends Message, R> {
552        @CheckForNull
553        R apply(M message) throws MessagingException;
554    }
555
556}