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}