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}