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