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;
017
018import java.net.URL;
019import java.security.InvalidAlgorithmParameterException;
020import java.security.KeyStore;
021import java.security.KeyStoreException;
022import java.security.cert.PKIXParameters;
023import java.security.cert.X509Certificate;
024import java.util.Collection;
025import java.util.Optional;
026import java.util.concurrent.atomic.AtomicReference;
027import java.util.regex.Pattern;
028
029import edu.umd.cs.findbugs.annotations.Nullable;
030import jakarta.mail.Message;
031import jakarta.mail.MessagingException;
032import jakarta.mail.Session;
033import jakarta.mail.internet.InternetAddress;
034import org.shredzone.acme4j.Identifier;
035import org.shredzone.acme4j.Login;
036import org.shredzone.acme4j.exception.AcmeProtocolException;
037import org.shredzone.acme4j.smime.challenge.EmailReply00Challenge;
038import org.shredzone.acme4j.smime.exception.AcmeInvalidMessageException;
039import org.shredzone.acme4j.smime.wrapper.Mail;
040import org.shredzone.acme4j.smime.wrapper.SignedMailBuilder;
041import org.shredzone.acme4j.smime.wrapper.SimpleMail;
042
043/**
044 * A processor for incoming "Challenge" emails.
045 *
046 * @see <a href="https://datatracker.ietf.org/doc/html/rfc8823">RFC 8823</a>
047 * @since 2.12
048 */
049public final class EmailProcessor {
050    private static final Pattern SUBJECT_PATTERN = Pattern.compile("ACME:\\s+([0-9A-Za-z_\\s-]+=?)\\s*");
051
052    private final InternetAddress sender;
053    private final InternetAddress recipient;
054    private final @Nullable String messageId;
055    private final Collection<InternetAddress> replyTo;
056    private final String token1;
057    private final AtomicReference<EmailReply00Challenge> challengeRef = new AtomicReference<>();
058
059    /**
060     * Processes the given plain e-mail message.
061     * <p>
062     * Note that according to RFC-8823, the challenge message must be signed using either
063     * DKIM or S/MIME. This method does not do any DKIM or S/MIME validation, and assumes
064     * that this has already been done in a previous stage.
065     *
066     * @param message
067     *         E-mail that was received from the CA. The inbound MTA has already taken
068     *         care of DKIM and/or S/MIME validation.
069     * @return EmailProcessor for this e-mail
070     * @throws AcmeInvalidMessageException
071     *         if a validation failed, and the message <em>must</em> be rejected.
072     * @since 2.15
073     */
074    public static EmailProcessor plainMessage(Message message)
075            throws AcmeInvalidMessageException {
076        return builder().skipVerification().build(message);
077    }
078
079    /**
080     * Processes the given signed e-mail message.
081     * <p>
082     * This method expects an S/MIME signed message. The signature must use a certificate
083     * that can be validated using Java's cacert truststore. Strict validation rules are
084     * applied.
085     * <p>
086     * Use the {@link #builder()} method if you need to configure the validation process.
087     *
088     * @param message
089     *         S/MIME signed e-mail that was received from the CA.
090     * @return EmailProcessor for this e-mail
091     * @throws AcmeInvalidMessageException
092     *         if a validation failed, and the message <em>must</em> be rejected.
093     * @since 2.16
094     */
095    public static EmailProcessor signedMessage(Message message)
096            throws AcmeInvalidMessageException {
097        return builder().build(message);
098    }
099
100    /**
101     * Creates a {@link Builder} for building an {@link EmailProcessor} with individual
102     * configuration.
103     *
104     * @since 2.16
105     */
106    public static Builder builder() {
107        return new Builder();
108    }
109
110    /**
111     * Creates a new {@link EmailProcessor} for the incoming "Challenge" message.
112     * <p>
113     * The incoming message is validated against the requirements of RFC-8823.
114     *
115     * @param message
116     *         "Challenge" message as it was sent by the CA.
117     * @throws AcmeInvalidMessageException
118     *         if a validation failed, and the message <em>must</em> be rejected.
119     */
120    private EmailProcessor(Mail message) throws AcmeInvalidMessageException {
121        if (!message.isAutoSubmitted()) {
122            throw new AcmeInvalidMessageException("Message is not auto-generated");
123        }
124
125        var subject = message.getSubject();
126        var m = SUBJECT_PATTERN.matcher(subject);
127        if (!m.matches()) {
128            throw new AcmeProtocolException("Invalid subject: " + subject);
129        }
130        // white spaces within the token part must be ignored
131        this.token1 = m.group(1).replaceAll("\\s+", "");
132
133        this.sender = message.getFrom();
134        this.recipient = message.getTo();
135        this.messageId = message.getMessageId().orElse(null);
136        this.replyTo = message.getReplyTo();
137    }
138
139    /**
140     * The expected sender of the "challenge" email.
141     * <p>
142     * The sender is usually checked when the {@link EmailReply00Challenge} is passed into
143     * the processor, but you can also manually check the sender here.
144     *
145     * @param expectedSender
146     *         The expected sender of the "challenge" email.
147     * @return itself
148     * @throws AcmeProtocolException
149     *         if the expected sender does not match
150     */
151    public EmailProcessor expectedFrom(InternetAddress expectedSender) {
152        requireNonNull(expectedSender, "expectedSender");
153        if (!sender.equals(expectedSender)) {
154            throw new AcmeProtocolException("Message is not sent by the expected sender");
155        }
156        return this;
157    }
158
159    /**
160     * The expected recipient of the "challenge" email.
161     * <p>
162     * This must be the email address of the entity that requested the S/MIME certificate.
163     * The check is not performed by the processor, but <em>should</em> be performed by
164     * the client.
165     *
166     * @param expectedRecipient
167     *         The expected recipient of the "challenge" email.
168     * @return itself
169     * @throws AcmeProtocolException
170     *         if the expected recipient does not match
171     */
172    public EmailProcessor expectedTo(InternetAddress expectedRecipient) {
173        requireNonNull(expectedRecipient, "expectedRecipient");
174        if (!recipient.equals(expectedRecipient)) {
175            throw new AcmeProtocolException("Message is not addressed to expected recipient");
176        }
177        return this;
178    }
179
180    /**
181     * The expected identifier.
182     * <p>
183     * This must be the email address of the entity that requested the S/MIME certificate.
184     * The check is not performed by the processor, but <em>should</em> be performed by
185     * the client.
186     *
187     * @param expectedIdentifier
188     *         The expected identifier for the S/MIME certificate. Usually this is an
189     *         {@link org.shredzone.acme4j.smime.EmailIdentifier} instance.
190     * @return itself
191     * @throws AcmeProtocolException
192     *         if the expected identifier is not an email identifier, or does not match
193     */
194    public EmailProcessor expectedIdentifier(Identifier expectedIdentifier) {
195        requireNonNull(expectedIdentifier, "expectedIdentifier");
196        if (!"email".equals(expectedIdentifier.getType())) {
197            throw new AcmeProtocolException("Wrong identifier type: " + expectedIdentifier.getType());
198        }
199        try {
200            expectedTo(new InternetAddress(expectedIdentifier.getValue()));
201        } catch (MessagingException ex) {
202            throw new AcmeProtocolException("Invalid email address", ex);
203        }
204        return this;
205    }
206
207    /**
208     * Returns the sender of the "challenge" email.
209     */
210    public InternetAddress getSender() {
211        return sender;
212    }
213
214    /**
215     * Returns the recipient of the "challenge" email.
216     */
217    public InternetAddress getRecipient() {
218        return recipient;
219    }
220
221    /**
222     * Returns all "reply-to" email addresses found in the "challenge" email.
223     * <p>
224     * Empty if there was no reply-to header, but never {@code null}.
225     */
226    public Collection<InternetAddress> getReplyTo() {
227        return replyTo;
228    }
229
230    /**
231     * Returns the message-id of the "challenge" email.
232     * <p>
233     * Empty if the challenge email has no message-id.
234     */
235    public Optional<String> getMessageId() {
236        return Optional.ofNullable(messageId);
237    }
238
239    /**
240     * Returns the "token 1" found in the subject of the "challenge" email.
241     */
242    public String getToken1() {
243        return token1;
244    }
245
246    /**
247     * Sets the corresponding {@link EmailReply00Challenge} that was received from the CA
248     * for validation.
249     *
250     * @param challenge
251     *         {@link EmailReply00Challenge} that corresponds to this email
252     * @return itself
253     * @throws AcmeProtocolException
254     *         if the challenge does not match this "challenge" email.
255     */
256    public EmailProcessor withChallenge(EmailReply00Challenge challenge) {
257        requireNonNull(challenge, "challenge");
258        expectedFrom(challenge.getExpectedSender());
259        if (challengeRef.get() != null) {
260            throw new IllegalStateException("A challenge has already been set");
261        }
262        challengeRef.set(challenge);
263        return this;
264    }
265
266    /**
267     * Sets the corresponding {@link EmailReply00Challenge} that was received from the CA
268     * for validation.
269     * <p>
270     * This is a convenience call in case that only the challenge location URL is
271     * available.
272     *
273     * @param login
274     *         A valid {@link Login}
275     * @param challengeLocation
276     *         The location URL of the corresponding challenge.
277     * @return itself
278     * @throws AcmeProtocolException
279     *         if the challenge does not match this "challenge" email.
280     */
281    public EmailProcessor withChallenge(Login login, URL challengeLocation) {
282        return withChallenge(login.bindChallenge(challengeLocation, EmailReply00Challenge.class));
283    }
284
285    /**
286     * Returns the full token of this challenge.
287     * <p>
288     * The corresponding email-reply-00 challenge must be set before.
289     */
290    public String getToken() {
291        checkChallengePresent();
292        return challengeRef.get().getToken(getToken1());
293    }
294
295    /**
296     * Returns the key-authorization of this challenge. This is the response to be used in
297     * the response email.
298     * <p>
299     * The corresponding email-reply-00 challenge must be set before.
300     */
301    public String getAuthorization() {
302        checkChallengePresent();
303        return challengeRef.get().getAuthorization(getToken1());
304    }
305
306    /**
307     * Returns a {@link ResponseGenerator} for generating a response email.
308     * <p>
309     * The corresponding email-reply-00 challenge must be set before.
310     */
311    public ResponseGenerator respond() {
312        checkChallengePresent();
313        return new ResponseGenerator(this);
314    }
315
316    /**
317     * Checks if a challenge has been set. Throws an exception if not.
318     */
319    private void checkChallengePresent() {
320        if (challengeRef.get() == null) {
321            throw new IllegalStateException("No challenge has been set yet");
322        }
323    }
324
325    /**
326     * A builder for {@link EmailProcessor}.
327     * <p>
328     * Use {@link EmailProcessor#builder()} to generate an instance.
329     *
330     * @since 2.16
331     */
332    public static class Builder {
333        private boolean unsigned = false;
334        private final SignedMailBuilder builder = new SignedMailBuilder();
335
336        private Builder() {
337            // Private constructor
338        }
339
340        /**
341         * Skips signature and header verification. Use only if the message has already
342         * been verified in a previous stage (e.g. by the MTA) or for testing purposes.
343         */
344        public Builder skipVerification() {
345            this.unsigned = true;
346            return this;
347        }
348
349        /**
350         * Uses the standard cacerts truststore for signature verification. This is the
351         * default.
352         */
353        public Builder caCerts() {
354            builder.withCaCertsTrustStore();
355            return this;
356        }
357
358        /**
359         * Uses the given truststore for signature verification.
360         * <p>
361         * This is for self-signed certificates. No revocation checks will take place.
362         *
363         * @param trustStore
364         *         {@link KeyStore} of the truststore to be used.
365         */
366        public Builder trustStore(KeyStore trustStore) {
367            try {
368                builder.withTrustStore(trustStore);
369            } catch (KeyStoreException | InvalidAlgorithmParameterException ex) {
370                throw new IllegalArgumentException("Cannot use trustStore", ex);
371            }
372            return this;
373        }
374
375        /**
376         * Uses the given certificate for signature verification.
377         * <p>
378         * This is for self-signed certificates. No revocation checks will take place.
379         *
380         * @param certificate
381         *         {@link X509Certificate} of the CA
382         */
383        public Builder certificate(X509Certificate certificate) {
384            builder.withSignCert(certificate);
385            return this;
386        }
387
388        /**
389         * Uses the given {@link PKIXParameters}.
390         *
391         * @param param
392         *         {@link PKIXParameters} to be used for signature verification.
393         */
394        public Builder pkixParameters(PKIXParameters param) {
395            builder.withPKIXParameters(param);
396            return this;
397        }
398
399        /**
400         * Uses the given mail {@link Session} for accessing the signed message body. A
401         * simple default session is used otherwise, which is usually sufficient.
402         *
403         * @param session
404         *         {@link Session} to be used for accessing the message body.
405         */
406        public Builder mailSession(Session session) {
407            builder.withMailSession(session);
408            return this;
409        }
410
411        /**
412         * Performs strict checks. Secured headers must exactly match their unsecured
413         * counterparts. This is the default.
414         */
415        public Builder strict() {
416            builder.relaxed(false);
417            return this;
418        }
419
420        /**
421         * Performs relaxed checks. Secured headers might differ in whitespaces or case of
422         * the field names. Use this if your MTA has mangled the envelope header.
423         */
424        public Builder relaxed() {
425            builder.relaxed(true);
426            return this;
427        }
428
429        /**
430         * Builds an {@link EmailProcessor} for the given {@link Message} using the
431         * current configuration.
432         *
433         * @param message
434         *         {@link Message} to create an {@link EmailProcessor} for.
435         * @return The generated {@link EmailProcessor}
436         * @throws AcmeInvalidMessageException
437         *         if the message fails to be verified. If this exception is thrown, the
438         *         message MUST be rejected, and MUST NOT be used for certification.
439         */
440        public EmailProcessor build(Message message) throws AcmeInvalidMessageException {
441            if (unsigned) {
442                return new EmailProcessor(new SimpleMail(message));
443            } else {
444                return new EmailProcessor(builder.build(message));
445            }
446        }
447    }
448
449}