mirror of https://github.com/shred/acme4j
Enhance validation of S/MIME challenge messages [WIP]
- Fully support S/MIME header protection - Validation of signature using cacerts, certificates or other trust stores - Improved checks of protected headers Still missing: - Unit tests - EmailProcessor supporting all new featurespull/134/head
parent
8535bb1698
commit
6f0a5c8707
|
@ -14,46 +14,31 @@
|
|||
package org.shredzone.acme4j.smime.email;
|
||||
|
||||
import static java.util.Objects.requireNonNull;
|
||||
import static jakarta.mail.Message.RecipientType.TO;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URL;
|
||||
import java.security.cert.CertificateParsingException;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import edu.umd.cs.findbugs.annotations.CheckForNull;
|
||||
import edu.umd.cs.findbugs.annotations.Nullable;
|
||||
import jakarta.mail.Address;
|
||||
import jakarta.mail.Message;
|
||||
import jakarta.mail.MessagingException;
|
||||
import jakarta.mail.Session;
|
||||
import jakarta.mail.internet.AddressException;
|
||||
import jakarta.mail.internet.InternetAddress;
|
||||
import jakarta.mail.internet.MimeMessage;
|
||||
import jakarta.mail.internet.MimeMultipart;
|
||||
import org.bouncycastle.cms.CMSException;
|
||||
import org.bouncycastle.cms.SignerInformation;
|
||||
import org.bouncycastle.cms.SignerInformationVerifier;
|
||||
import org.bouncycastle.cms.jcajce.JcaSimpleSignerInfoVerifierBuilder;
|
||||
import org.bouncycastle.mail.smime.SMIMESigned;
|
||||
import org.bouncycastle.operator.OperatorCreationException;
|
||||
import org.shredzone.acme4j.Identifier;
|
||||
import org.shredzone.acme4j.Login;
|
||||
import org.shredzone.acme4j.exception.AcmeProtocolException;
|
||||
import org.shredzone.acme4j.smime.challenge.EmailReply00Challenge;
|
||||
import org.shredzone.acme4j.smime.exception.AcmeInvalidMessageException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.shredzone.acme4j.smime.wrapper.Mail;
|
||||
import org.shredzone.acme4j.smime.wrapper.SignedMail;
|
||||
import org.shredzone.acme4j.smime.wrapper.SignedMailBuilder;
|
||||
import org.shredzone.acme4j.smime.wrapper.SimpleMail;
|
||||
|
||||
/**
|
||||
* A processor for incoming "Challenge" emails.
|
||||
|
@ -62,16 +47,13 @@ import org.slf4j.LoggerFactory;
|
|||
* @since 2.12
|
||||
*/
|
||||
public final class EmailProcessor {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(EmailProcessor.class);
|
||||
private static final Pattern SUBJECT_PATTERN = Pattern.compile("ACME:\\s+([0-9A-Za-z_\\s-]+=?)\\s*");
|
||||
private static final int RFC822NAME = 1;
|
||||
|
||||
private final String token1;
|
||||
private final Optional<String> messageId;
|
||||
private final InternetAddress sender;
|
||||
private final InternetAddress recipient;
|
||||
private final @Nullable String messageId;
|
||||
private final Collection<InternetAddress> replyTo;
|
||||
private final String token1;
|
||||
private final AtomicReference<EmailReply00Challenge> challengeRef = new AtomicReference<>();
|
||||
|
||||
/**
|
||||
|
@ -91,7 +73,7 @@ public final class EmailProcessor {
|
|||
*/
|
||||
public static EmailProcessor plainMessage(Message message)
|
||||
throws AcmeInvalidMessageException {
|
||||
return new EmailProcessor(message, null, false, null);
|
||||
return new EmailProcessor(new SimpleMail(message));
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -118,64 +100,12 @@ public final class EmailProcessor {
|
|||
public static EmailProcessor smimeMessage(Message message, Session mailSession,
|
||||
X509Certificate signCert, boolean strict)
|
||||
throws AcmeInvalidMessageException {
|
||||
try {
|
||||
if (!(message instanceof MimeMessage)) {
|
||||
throw new AcmeInvalidMessageException("Not a S/MIME message");
|
||||
}
|
||||
MimeMessage mimeMessage = (MimeMessage) message;
|
||||
|
||||
if (!(mimeMessage.getContent() instanceof MimeMultipart)) {
|
||||
throw new AcmeProtocolException("S/MIME signed email must contain MimeMultipart");
|
||||
}
|
||||
MimeMultipart mp = (MimeMultipart) message.getContent();
|
||||
|
||||
SMIMESigned signed = new SMIMESigned(mp);
|
||||
|
||||
SignerInformationVerifier verifier = new JcaSimpleSignerInfoVerifierBuilder().build(signCert);
|
||||
boolean hasMatch = false;
|
||||
for (SignerInformation signer : signed.getSignerInfos().getSigners()) {
|
||||
hasMatch |= signer.verify(verifier);
|
||||
if (hasMatch) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!hasMatch) {
|
||||
throw new AcmeInvalidMessageException("The S/MIME signature is invalid");
|
||||
}
|
||||
|
||||
MimeMessage content = signed.getContentAsMimeMessage(mailSession);
|
||||
if (!"message/rfc822; forwarded=no".equalsIgnoreCase(content.getContentType())) {
|
||||
throw new AcmeInvalidMessageException("Message does not contain protected headers");
|
||||
}
|
||||
|
||||
MimeMessage body = new MimeMessage(mailSession, content.getInputStream());
|
||||
|
||||
List<Address> validFromAddresses = Optional.ofNullable(signCert.getSubjectAlternativeNames())
|
||||
.orElseGet(Collections::emptyList)
|
||||
.stream()
|
||||
.filter(l -> ((Number) l.get(0)).intValue() == RFC822NAME)
|
||||
.map(l -> l.get(1).toString())
|
||||
.map(l -> {
|
||||
try {
|
||||
return new InternetAddress(l);
|
||||
} catch (AddressException ex) {
|
||||
// Ignore invalid email addresses
|
||||
LOG.debug("Certificate contains invalid e-mail address {}", l, ex);
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
if (validFromAddresses.isEmpty()) {
|
||||
throw new AcmeInvalidMessageException("Signing certificate does not provide a rfc822Name subjectAltName");
|
||||
}
|
||||
|
||||
return new EmailProcessor(message, body, strict, validFromAddresses);
|
||||
} catch (IOException | MessagingException | CMSException | OperatorCreationException |
|
||||
CertificateParsingException ex) {
|
||||
throw new AcmeInvalidMessageException("Invalid S/MIME mail", ex);
|
||||
}
|
||||
SignedMail mail = new SignedMailBuilder()
|
||||
.withSignCert(signCert)
|
||||
.relaxed(!strict)
|
||||
.withMailSession(mailSession)
|
||||
.build(message);
|
||||
return new EmailProcessor(mail);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -185,101 +115,26 @@ public final class EmailProcessor {
|
|||
*
|
||||
* @param message
|
||||
* "Challenge" message as it was sent by the CA.
|
||||
* @param signedMessage
|
||||
* The signed part of the challenge message if present, or {@code null}. The
|
||||
* signature is assumed to be valid, and must be validated in a previous
|
||||
* step.
|
||||
* @param strict
|
||||
* If {@code true}, the S/MIME protected headers "From", "To", and "Subject"
|
||||
* <em>must</em> match the headers of the received message. If {@code false},
|
||||
* only the S/MIME protected headers are used, and the headers of the received
|
||||
* message are ignored.
|
||||
* @param validFromAddresses
|
||||
* A {@link List} of {@link Address} that were found in the certificate's
|
||||
* rfc822Name subjectAltName extension. The mail's From address <em>must</em>
|
||||
* be found in this list, otherwise the signed message will be rejected.
|
||||
* {@code null} to disable this validation step.
|
||||
* @throws AcmeInvalidMessageException
|
||||
* if a validation failed, and the message <em>must</em> be rejected.
|
||||
*/
|
||||
private EmailProcessor(Message message, @Nullable MimeMessage signedMessage,
|
||||
boolean strict, @Nullable List<Address> validFromAddresses)
|
||||
throws AcmeInvalidMessageException {
|
||||
requireNonNull(message, "message");
|
||||
|
||||
// Validate challenge and extract token 1
|
||||
try {
|
||||
if (!isAutoGenerated(getOptional(m -> m.getHeader("Auto-Submitted"), message, signedMessage))) {
|
||||
throw new AcmeInvalidMessageException("Message is not auto-generated");
|
||||
}
|
||||
|
||||
Address[] from = getMandatory(Message::getFrom, message, signedMessage, "From");
|
||||
if (from == null) {
|
||||
throw new AcmeInvalidMessageException("Message has no 'From' header");
|
||||
}
|
||||
if (from.length != 1 || from[0] == null) {
|
||||
throw new AcmeInvalidMessageException("Message must have exactly one sender, but has " + from.length);
|
||||
}
|
||||
if (validFromAddresses != null && !validFromAddresses.contains(from[0])) {
|
||||
throw new AcmeInvalidMessageException("Sender '" + from[0] + "' was not found in signing certificate");
|
||||
}
|
||||
if (strict && signedMessage != null) {
|
||||
Address[] outerFrom = message.getFrom();
|
||||
if (outerFrom == null || outerFrom.length != 1 || !from[0].equals(outerFrom[0])) {
|
||||
throw new AcmeInvalidMessageException("Protected 'From' header does not match envelope header");
|
||||
}
|
||||
}
|
||||
sender = new InternetAddress(from[0].toString());
|
||||
|
||||
Address[] to = getMandatory(m -> m.getRecipients(TO), message, signedMessage, "To");
|
||||
if (to == null) {
|
||||
throw new AcmeInvalidMessageException("Message has no 'To' header");
|
||||
}
|
||||
if (to.length != 1 || to[0] == null) {
|
||||
throw new AcmeProtocolException("Message must have exactly one recipient, but has " + to.length);
|
||||
}
|
||||
if (strict && signedMessage != null) {
|
||||
Address[] outerTo = message.getRecipients(TO);
|
||||
if (outerTo == null || outerTo.length != 1 || !to[0].equals(outerTo[0])) {
|
||||
throw new AcmeInvalidMessageException("Protected 'To' header does not match envelope header");
|
||||
}
|
||||
}
|
||||
recipient = new InternetAddress(to[0].toString());
|
||||
|
||||
String subject = getMandatory(Message::getSubject, message, signedMessage, "Subject");
|
||||
if (subject == null) {
|
||||
throw new AcmeInvalidMessageException("Message has no 'Subject' header");
|
||||
}
|
||||
if (strict && signedMessage != null &&
|
||||
(message.getSubject() == null || !message.getSubject().equals(signedMessage.getSubject()))) {
|
||||
throw new AcmeInvalidMessageException("Protected 'Subject' header does not match envelope header");
|
||||
}
|
||||
Matcher m = SUBJECT_PATTERN.matcher(subject);
|
||||
if (!m.matches()) {
|
||||
throw new AcmeProtocolException("Invalid subject: " + subject);
|
||||
}
|
||||
// white spaces within the token part must be ignored
|
||||
this.token1 = m.group(1).replaceAll("\\s+", "");
|
||||
|
||||
Address[] rto = getOptional(Message::getReplyTo, message, signedMessage);
|
||||
if (rto != null) {
|
||||
replyTo = Collections.unmodifiableList(Arrays.stream(rto)
|
||||
.filter(InternetAddress.class::isInstance)
|
||||
.map(InternetAddress.class::cast)
|
||||
.collect(Collectors.toList()));
|
||||
} else {
|
||||
replyTo = Collections.emptyList();
|
||||
}
|
||||
|
||||
String[] mid = getOptional(n -> n.getHeader("Message-ID"), message, signedMessage);
|
||||
if (mid != null && mid.length > 0) {
|
||||
messageId = Optional.of(mid[0]);
|
||||
} else {
|
||||
messageId = Optional.empty();
|
||||
}
|
||||
} catch (MessagingException ex) {
|
||||
throw new AcmeProtocolException("Invalid challenge email", ex);
|
||||
private EmailProcessor(Mail message) throws AcmeInvalidMessageException {
|
||||
if (!message.isAutoSubmitted()) {
|
||||
throw new AcmeInvalidMessageException("Message is not auto-generated");
|
||||
}
|
||||
|
||||
String subject = message.getSubject();
|
||||
Matcher m = SUBJECT_PATTERN.matcher(subject);
|
||||
if (!m.matches()) {
|
||||
throw new AcmeProtocolException("Invalid subject: " + subject);
|
||||
}
|
||||
// white spaces within the token part must be ignored
|
||||
this.token1 = m.group(1).replaceAll("\\s+", "");
|
||||
|
||||
this.sender = message.getFrom();
|
||||
this.recipient = message.getTo();
|
||||
this.messageId = message.getMessageId().orElse(null);
|
||||
this.replyTo = message.getReplyTo();
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -379,7 +234,7 @@ public final class EmailProcessor {
|
|||
* Empty if the challenge email has no message-id.
|
||||
*/
|
||||
public Optional<String> getMessageId() {
|
||||
return messageId;
|
||||
return Optional.ofNullable(messageId);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -0,0 +1,84 @@
|
|||
/*
|
||||
* acme4j - Java ACME client
|
||||
*
|
||||
* Copyright (C) 2023 Richard "Shred" Körber
|
||||
* http://acme4j.shredzone.org
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
*/
|
||||
package org.shredzone.acme4j.smime.wrapper;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Optional;
|
||||
|
||||
import jakarta.mail.internet.InternetAddress;
|
||||
import org.shredzone.acme4j.smime.exception.AcmeInvalidMessageException;
|
||||
|
||||
/**
|
||||
* Provide access to all required fields of an email. The underlying implementation
|
||||
* has to take care about parsing, validation, and verification of security features.
|
||||
*
|
||||
* @since 2.16
|
||||
*/
|
||||
public interface Mail {
|
||||
|
||||
/**
|
||||
* Returns the sender address.
|
||||
*
|
||||
* @throws AcmeInvalidMessageException
|
||||
* if there is not exactly one "From" header, or if the sender address is
|
||||
* invalid.
|
||||
*/
|
||||
InternetAddress getFrom() throws AcmeInvalidMessageException;
|
||||
|
||||
/**
|
||||
* Returns the recipient address.
|
||||
*
|
||||
* @throws AcmeInvalidMessageException
|
||||
* if there is not exactly one "To" header, or if the recipient address is
|
||||
* invalid.
|
||||
*/
|
||||
InternetAddress getTo() throws AcmeInvalidMessageException;
|
||||
|
||||
/**
|
||||
* Returns the subject.
|
||||
*
|
||||
* @throws AcmeInvalidMessageException if there is no "Subject" header.
|
||||
*/
|
||||
String getSubject() throws AcmeInvalidMessageException;
|
||||
|
||||
/**
|
||||
* Returns a collection of the reply-to addresses. Reply-to addresses that are not
|
||||
* {@link InternetAddress} type are ignored.
|
||||
*
|
||||
* @return Collection of reply-to addresses. May be empty, but is never {@code null}.
|
||||
* @throws AcmeInvalidMessageException
|
||||
* if the "Reply-To" header could not be parsed
|
||||
*/
|
||||
Collection<InternetAddress> getReplyTo() throws AcmeInvalidMessageException;
|
||||
|
||||
/**
|
||||
* Returns the message ID.
|
||||
*
|
||||
* @return Message ID, or empty if there is no message ID header.
|
||||
* @throws AcmeInvalidMessageException
|
||||
* if the "Message-ID" header could not be parsed
|
||||
*/
|
||||
Optional<String> getMessageId() throws AcmeInvalidMessageException;
|
||||
|
||||
/**
|
||||
* Checks if the mail was flagged as auto-generated.
|
||||
*
|
||||
* @return {@code true} if there is an "Auto-Submitted" header containing the string
|
||||
* "auto-generated", {@code false} otherwise.
|
||||
* @throws AcmeInvalidMessageException
|
||||
* if the "Auto-Submitted" header could not be parsed.
|
||||
*/
|
||||
boolean isAutoSubmitted() throws AcmeInvalidMessageException;
|
||||
|
||||
}
|
|
@ -0,0 +1,469 @@
|
|||
/*
|
||||
* acme4j - Java ACME client
|
||||
*
|
||||
* Copyright (C) 2023 Richard "Shred" Körber
|
||||
* http://acme4j.shredzone.org
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
*/
|
||||
package org.shredzone.acme4j.smime.wrapper;
|
||||
|
||||
import static java.util.Arrays.asList;
|
||||
import static java.util.Collections.unmodifiableSet;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.Enumeration;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import edu.umd.cs.findbugs.annotations.Nullable;
|
||||
import jakarta.mail.Header;
|
||||
import jakarta.mail.Message;
|
||||
import jakarta.mail.internet.AddressException;
|
||||
import jakarta.mail.internet.InternetAddress;
|
||||
import org.bouncycastle.asn1.ASN1Encodable;
|
||||
import org.bouncycastle.asn1.ASN1Enumerated;
|
||||
import org.bouncycastle.asn1.ASN1Integer;
|
||||
import org.bouncycastle.asn1.ASN1ObjectIdentifier;
|
||||
import org.bouncycastle.asn1.ASN1Sequence;
|
||||
import org.bouncycastle.asn1.ASN1Set;
|
||||
import org.bouncycastle.asn1.ASN1String;
|
||||
import org.bouncycastle.asn1.cms.Attribute;
|
||||
import org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers;
|
||||
import org.bouncycastle.cms.SignerInformation;
|
||||
import org.shredzone.acme4j.smime.exception.AcmeInvalidMessageException;
|
||||
|
||||
/**
|
||||
* Represents a signed {@link Message}.
|
||||
* <p>
|
||||
* This class is generated by {@link SignedMailBuilder}, which also takes care for
|
||||
* signature verification and validation.
|
||||
*
|
||||
* @see <a href="https://www.rfc-editor.org/rfc/rfc7508.html">RFC 7508</a>
|
||||
* @since 2.16
|
||||
*/
|
||||
public class SignedMail implements Mail {
|
||||
private static final ASN1ObjectIdentifier SECURE_HEADER_FIELDS_ID
|
||||
= PKCSObjectIdentifiers.pkcs_9.branch("16.2.55");
|
||||
private static final Set<String> IGNORE_HEADERS
|
||||
= unmodifiableSet(new TreeSet<>(asList("CONTENT-TYPE", "MIME-VERSION", "RECEIVED")));
|
||||
private static final Set<String> REQUIRED_HEADERS
|
||||
= unmodifiableSet(new TreeSet<>(asList("FROM", "TO", "SUBJECT")));
|
||||
|
||||
private final List<MailHeader> headers = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* This class is to be constructed only by {@link SignedMailBuilder}.
|
||||
*/
|
||||
SignedMail() {
|
||||
// package protected constructor
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports untrusted headers from the envelope message.
|
||||
* <p>
|
||||
* All previously imported headers are cleaned before that.
|
||||
*/
|
||||
public void importUntrustedHeaders(Enumeration<Header> en) {
|
||||
headers.clear();
|
||||
while (en.hasMoreElements()) {
|
||||
Header h = en.nextElement();
|
||||
String name = h.getName();
|
||||
if (IGNORE_HEADERS.contains(name.toUpperCase())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
headers.add(new MailHeader(name, h.getValue()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports secured headers from the signed, inner message.
|
||||
* <p>
|
||||
* The import is strict. If a secured header is also present in the envelope message,
|
||||
* it must match exactly.
|
||||
*
|
||||
* @throws AcmeInvalidMessageException
|
||||
* if the secured header was found in the envelope message, but did not match.
|
||||
*/
|
||||
public void importTrustedHeaders(Enumeration<Header> en) throws AcmeInvalidMessageException {
|
||||
while (en.hasMoreElements()) {
|
||||
Header h = en.nextElement();
|
||||
String name = h.getName();
|
||||
if (IGNORE_HEADERS.contains(name.toUpperCase())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String value = h.getValue();
|
||||
long count = headers.stream()
|
||||
.filter(mh -> mh.nameEquals(name, false) && mh.valueEquals(value, false))
|
||||
.peek(MailHeader::setTrusted)
|
||||
.count();
|
||||
|
||||
if (count == 0) {
|
||||
throw new AcmeInvalidMessageException("Secured header '" + name
|
||||
+ "' does not match envelope header");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports secured headers from the signed, inner message.
|
||||
* <p>
|
||||
* The import is relaxed. If the secured header is also found in the envelope message
|
||||
* header, it will replace the envelope header.
|
||||
*/
|
||||
public void importTrustedHeadersRelaxed(Enumeration<Header> en) {
|
||||
while (en.hasMoreElements()) {
|
||||
Header h = en.nextElement();
|
||||
String name = h.getName();
|
||||
if (IGNORE_HEADERS.contains(name.toUpperCase())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
headers.removeIf(mh -> mh.nameEquals(name, true) && !mh.trusted);
|
||||
headers.add(new MailHeader(name, h.getValue()).setTrusted());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports secured headers from the signature.
|
||||
* <p>
|
||||
* Depending on the signature, the envelope header is either checked, deleted, or
|
||||
* modified.
|
||||
*
|
||||
* @throws AcmeInvalidMessageException
|
||||
* if the signature header conflicts with the envelope header.
|
||||
*/
|
||||
public void importSignatureHeaders(SignerInformation si) throws AcmeInvalidMessageException {
|
||||
Attribute attr = si.getSignedAttributes().get(SECURE_HEADER_FIELDS_ID);
|
||||
if (attr == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
boolean relaxed = false;
|
||||
for (ASN1Encodable element : (ASN1Set) attr.getAttributeValues()[0]) {
|
||||
if (element instanceof ASN1Enumerated) {
|
||||
int algorithm = ((ASN1Enumerated) element).intValueExact();
|
||||
switch (algorithm) {
|
||||
case 0:
|
||||
relaxed = false;
|
||||
break;
|
||||
case 1:
|
||||
relaxed = true;
|
||||
break;
|
||||
default:
|
||||
throw new AcmeInvalidMessageException("Unknown algorithm: " + algorithm);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (ASN1Encodable element : (ASN1Set) attr.getAttributeValues()[0]) {
|
||||
if (element instanceof ASN1Sequence) {
|
||||
for (ASN1Encodable sequenceElement : (ASN1Sequence) element) {
|
||||
ASN1Sequence headerField = (ASN1Sequence) sequenceElement;
|
||||
String fieldName = ((ASN1String) headerField.getObjectAt(0)).getString();
|
||||
String fieldValue = ((ASN1String) headerField.getObjectAt(1)).getString();
|
||||
int fieldStatus = 0;
|
||||
if (headerField.size() >= 3) {
|
||||
fieldStatus = ((ASN1Integer) headerField.getObjectAt(2)).intValueExact();
|
||||
}
|
||||
switch (fieldStatus) {
|
||||
case 0:
|
||||
checkDuplicatedField(fieldName, fieldValue, relaxed);
|
||||
break;
|
||||
case 1:
|
||||
deleteField(fieldName, fieldValue, relaxed);
|
||||
break;
|
||||
case 2:
|
||||
modifyField(fieldName, fieldValue, relaxed);
|
||||
break;
|
||||
default:
|
||||
throw new AcmeInvalidMessageException("Unknown field status " + fieldStatus);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public InternetAddress getFrom() throws AcmeInvalidMessageException {
|
||||
try {
|
||||
return new InternetAddress(fetchTrustedHeader("FROM"));
|
||||
} catch (AddressException ex) {
|
||||
throw new AcmeInvalidMessageException("Invalid 'FROM' address", ex);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public InternetAddress getTo() throws AcmeInvalidMessageException {
|
||||
try {
|
||||
return new InternetAddress(fetchTrustedHeader("TO"));
|
||||
} catch (AddressException ex) {
|
||||
throw new AcmeInvalidMessageException("Invalid 'TO' address", ex);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getSubject() throws AcmeInvalidMessageException {
|
||||
return fetchTrustedHeader("SUBJECT");
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<String> getMessageId() {
|
||||
return headers.stream()
|
||||
.filter(mh -> "MESSAGE-ID".equalsIgnoreCase(mh.name))
|
||||
.map(mh -> mh.value)
|
||||
.map(String::trim)
|
||||
.findFirst();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<InternetAddress> getReplyTo() throws AcmeInvalidMessageException {
|
||||
List<String> replyToList = headers.stream()
|
||||
.filter(mh -> "REPLY-TO".equalsIgnoreCase(mh.name))
|
||||
.map(mh -> mh.value)
|
||||
.map(String::trim)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
if (replyToList.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
try {
|
||||
List<InternetAddress> result = new ArrayList<>(replyToList.size());
|
||||
for (String replyTo : replyToList) {
|
||||
result.add(new InternetAddress(replyTo));
|
||||
}
|
||||
return Collections.unmodifiableList(result);
|
||||
} catch (AddressException ex) {
|
||||
throw new AcmeInvalidMessageException("Invalid 'REPLY-TO' address", ex);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isAutoSubmitted() {
|
||||
return headers.stream()
|
||||
.filter(mh -> "AUTO-SUBMITTED".equalsIgnoreCase(mh.name))
|
||||
.map(mh -> mh.value)
|
||||
.map(String::trim)
|
||||
.map(String::toLowerCase)
|
||||
.anyMatch(h -> h.equals("auto-generated") || h.startsWith("auto-generated;"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a set of missing, but required secured headers. This list is supposed to
|
||||
* be empty on valid messages with secured headers. If there is at least one element,
|
||||
* the message must be refused.
|
||||
*/
|
||||
public Set<String> getMissingSecuredHeaders() {
|
||||
Set<String> missing = new TreeSet<>(REQUIRED_HEADERS);
|
||||
headers.stream()
|
||||
.filter(mh -> mh.trusted)
|
||||
.map(mh -> mh.name)
|
||||
.map(String::toUpperCase)
|
||||
.forEach(missing::remove);
|
||||
return missing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes a "duplicated" header field status. The signature header must be found
|
||||
* with the same value in the envelope message header.
|
||||
*
|
||||
* @param header
|
||||
* Header name
|
||||
* @param value
|
||||
* Expected header value
|
||||
* @param relaxed
|
||||
* {@code false}: simple, {@code true}: relaxed algorithm
|
||||
* @throws AcmeInvalidMessageException
|
||||
* if a header with the same value was not found
|
||||
*/
|
||||
private void checkDuplicatedField(String header, String value, boolean relaxed) throws AcmeInvalidMessageException {
|
||||
long count = headers.stream()
|
||||
.filter(mh -> mh.nameEquals(header, relaxed) && mh.valueEquals(value, relaxed))
|
||||
.peek(MailHeader::setTrusted)
|
||||
.count();
|
||||
if (count == 0) {
|
||||
throw new AcmeInvalidMessageException("Secured header '" + header
|
||||
+ "' was not found in envelope header");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes a "deleted" header field status. The signature header must be found
|
||||
* with the same value in the envelope message header, and is then removed from the
|
||||
* header.
|
||||
*
|
||||
* @param header
|
||||
* Header name
|
||||
* @param value
|
||||
* Expected header value
|
||||
* @param relaxed
|
||||
* {@code false}: simple, {@code true}: relaxed algorithm
|
||||
* @throws AcmeInvalidMessageException
|
||||
* if a header with the same value was not found
|
||||
*/
|
||||
private void deleteField(String header, String value, boolean relaxed) throws AcmeInvalidMessageException {
|
||||
if (!headers.removeIf(mh -> mh.nameEquals(header, relaxed) && mh.valueEquals(value, relaxed))) {
|
||||
throw new AcmeInvalidMessageException("Secured header '" + header
|
||||
+ "' was not found in envelope header for deletion");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes a "modified" header field status. The signature header must be found in
|
||||
* the envelope message header, and is then replaced with the given value.
|
||||
*
|
||||
* @param header
|
||||
* Header name
|
||||
* @param value
|
||||
* New header value
|
||||
* @param relaxed
|
||||
* {@code false}: simple, {@code true}: relaxed algorithm
|
||||
* @throws AcmeInvalidMessageException
|
||||
* if the header was not found
|
||||
*/
|
||||
private void modifyField(String header, String value, boolean relaxed) throws AcmeInvalidMessageException {
|
||||
if (!headers.removeIf(mh -> mh.nameEquals(header, relaxed))) {
|
||||
throw new AcmeInvalidMessageException("Secured header '" + header
|
||||
+ "' was not found in envelope header for modification");
|
||||
}
|
||||
headers.add(new MailHeader(header, value).setTrusted());
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a trusted header. The header must be present exactly once, and must be
|
||||
* marked as trusted, i.e. it was either found in the signed inner message, or was
|
||||
* set by the signature headers.
|
||||
*
|
||||
* @param name
|
||||
* Name of the header, case-insensitive
|
||||
* @return Header value
|
||||
* @throws AcmeInvalidMessageException
|
||||
* if the header was not found, was found more than once, or is not marked as
|
||||
* trusted
|
||||
*/
|
||||
private String fetchTrustedHeader(String name) throws AcmeInvalidMessageException {
|
||||
List<String> candidates = headers.stream()
|
||||
.filter(mh -> name.equalsIgnoreCase(mh.name))
|
||||
.filter(mh -> mh.trusted)
|
||||
.map(mh -> mh.value)
|
||||
.map(String::trim)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
if (candidates.isEmpty()) {
|
||||
throw new AcmeInvalidMessageException("Protected '" + name
|
||||
+ "' header is required, but missing");
|
||||
}
|
||||
|
||||
if (candidates.size() > 1) {
|
||||
throw new AcmeInvalidMessageException("Expecting exactly one protected '"
|
||||
+ name + "' header, but found " + candidates.size());
|
||||
}
|
||||
|
||||
return candidates.get(0);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (MailHeader mh : headers) {
|
||||
sb.append(mh.toString()).append('\n');
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* A single mail header.
|
||||
*/
|
||||
private static class MailHeader {
|
||||
public final String name;
|
||||
public final String value;
|
||||
public boolean trusted;
|
||||
|
||||
/**
|
||||
* Creates a new mail header.
|
||||
*
|
||||
* @param name Header name
|
||||
* @param value Header value
|
||||
*/
|
||||
public MailHeader(String name, String value) {
|
||||
this.name = name;
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks this header as trusted.
|
||||
*
|
||||
* @return itself
|
||||
*/
|
||||
public MailHeader setTrusted() {
|
||||
trusted = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the header name equals the expected value.
|
||||
*
|
||||
* @param expected
|
||||
* Expected name
|
||||
* @param relaxed
|
||||
* {@code false}: names must match exactly, {@code true}: case-insensitive
|
||||
* match
|
||||
* @return {@code true} if equal
|
||||
*/
|
||||
public boolean nameEquals(@Nullable String expected, boolean relaxed) {
|
||||
if (!relaxed) {
|
||||
return name.equals(expected);
|
||||
}
|
||||
|
||||
if (expected == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return name.equalsIgnoreCase(expected);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the header value equals the expected value.
|
||||
*
|
||||
* @param expected
|
||||
* Expected value, may be {@code null}
|
||||
* @param relaxed
|
||||
* {@code false}: value must match exactly, {@code true}: differences in
|
||||
* whitespaces are ignored
|
||||
* @return {@code true} if equal
|
||||
*/
|
||||
public boolean valueEquals(@Nullable String expected, boolean relaxed) {
|
||||
if (!relaxed) {
|
||||
return value.equals(expected);
|
||||
}
|
||||
|
||||
if (expected == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String normalizedValue = value.replaceAll("\\s+", " ").trim();
|
||||
String normalizedExpected = expected.replaceAll("\\s+", " ").trim();
|
||||
return normalizedValue.equals(normalizedExpected);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return (trusted ? "* " : " ") + name + ": " + value;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,303 @@
|
|||
/*
|
||||
* acme4j - Java ACME client
|
||||
*
|
||||
* Copyright (C) 2023 Richard "Shred" Körber
|
||||
* http://acme4j.shredzone.org
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
*/
|
||||
package org.shredzone.acme4j.smime.wrapper;
|
||||
|
||||
import static java.util.Objects.requireNonNull;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.security.InvalidAlgorithmParameterException;
|
||||
import java.security.KeyStore;
|
||||
import java.security.KeyStoreException;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.cert.CertificateException;
|
||||
import java.security.cert.PKIXParameters;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.Collection;
|
||||
import java.util.Objects;
|
||||
import java.util.Properties;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import edu.umd.cs.findbugs.annotations.Nullable;
|
||||
import jakarta.mail.Message;
|
||||
import jakarta.mail.MessagingException;
|
||||
import jakarta.mail.Session;
|
||||
import jakarta.mail.internet.AddressException;
|
||||
import jakarta.mail.internet.InternetAddress;
|
||||
import jakarta.mail.internet.MimeMessage;
|
||||
import jakarta.mail.internet.MimeMultipart;
|
||||
import org.bouncycastle.asn1.x509.Extension;
|
||||
import org.bouncycastle.asn1.x509.GeneralName;
|
||||
import org.bouncycastle.asn1.x509.GeneralNames;
|
||||
import org.bouncycastle.cert.X509CertificateHolder;
|
||||
import org.bouncycastle.cms.CMSException;
|
||||
import org.bouncycastle.cms.SignerInformation;
|
||||
import org.bouncycastle.cms.SignerInformationStore;
|
||||
import org.bouncycastle.mail.smime.SMIMESigned;
|
||||
import org.bouncycastle.mail.smime.validator.SignedMailValidator;
|
||||
import org.bouncycastle.mail.smime.validator.SignedMailValidatorException;
|
||||
import org.shredzone.acme4j.exception.AcmeProtocolException;
|
||||
import org.shredzone.acme4j.smime.exception.AcmeInvalidMessageException;
|
||||
|
||||
/**
|
||||
* Creates a {@link SignedMail} instance from a signed message.
|
||||
*
|
||||
* @since 2.16
|
||||
*/
|
||||
public class SignedMailBuilder {
|
||||
private static final AtomicReference<KeyStore> CACERTS_TRUSTSTORE = new AtomicReference<>();
|
||||
|
||||
private Session mailSession = Session.getDefaultInstance(new Properties());
|
||||
private boolean relaxed = false;
|
||||
|
||||
@Nullable
|
||||
private PKIXParameters pkixParameters = null;
|
||||
|
||||
/**
|
||||
* Uses the given truststore for certificate validation.
|
||||
*
|
||||
* @param trustStore {@link KeyStore} to use.
|
||||
* @return itself
|
||||
*/
|
||||
public SignedMailBuilder withTrustStore(KeyStore trustStore)
|
||||
throws KeyStoreException, InvalidAlgorithmParameterException {
|
||||
requireNonNull(trustStore, "trustStore");
|
||||
return withPKIXParameters(new PKIXParameters(trustStore));
|
||||
}
|
||||
|
||||
/**
|
||||
* Uses the given {@link X509Certificate} for certificate validation.
|
||||
*
|
||||
* @param signCert {@link X509Certificate} to use.
|
||||
* @return itself
|
||||
*/
|
||||
public SignedMailBuilder withSignCert(X509Certificate signCert) {
|
||||
requireNonNull(signCert, "signCert");
|
||||
try {
|
||||
KeyStore ks = KeyStore.getInstance("JKS");
|
||||
ks.load(null, null);
|
||||
ks.setCertificateEntry("cert", signCert);
|
||||
return withTrustStore(ks);
|
||||
} catch (KeyStoreException | IOException | NoSuchAlgorithmException |
|
||||
CertificateException | InvalidAlgorithmParameterException ex) {
|
||||
throw new IllegalArgumentException("Invalid certificate", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Uses the given {@link PKIXParameters} for certificate validation.
|
||||
*
|
||||
* @param param {@link PKIXParameters} to use.
|
||||
* @return itself
|
||||
*/
|
||||
public SignedMailBuilder withPKIXParameters(PKIXParameters param) {
|
||||
this.pkixParameters = requireNonNull(param, "param");
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a different mail {@link Session} that is used for accessing the signed
|
||||
* email body.
|
||||
*
|
||||
* @param mailSession {@link Session} to use.
|
||||
* @return itself
|
||||
*/
|
||||
public SignedMailBuilder withMailSession(Session mailSession) {
|
||||
this.mailSession = requireNonNull(mailSession, "mailSession");
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes relaxed validation. If enabled, headers of the signed message body are
|
||||
* preferred if present, but do not need to match the appropriate headers of the
|
||||
* envelope message.
|
||||
* <p>
|
||||
* By default, relaxed validation is disabled.
|
||||
*
|
||||
* @param relaxed sets relaxed validation mode
|
||||
* @return itself
|
||||
*/
|
||||
public SignedMailBuilder relaxed(boolean relaxed) {
|
||||
this.relaxed = relaxed;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the message signature and message headers. If validation passes, a
|
||||
* {@link SignedMail} instance is returned that gives access to the trusted mail
|
||||
* headers.
|
||||
*
|
||||
* @param message {@link Message}, must be a {@link MimeMessage}.
|
||||
* @return SignedMail containing the trusted headers.
|
||||
* @throws AcmeInvalidMessageException
|
||||
* if the given message is invalid, its signature is invalid, or the secured
|
||||
* headers are invalid. If this exception is thrown, the message MUST be
|
||||
* rejected.
|
||||
*/
|
||||
public SignedMail build(Message message) throws AcmeInvalidMessageException {
|
||||
requireNonNull(message, "message");
|
||||
try {
|
||||
// Check all parameters
|
||||
if (!(message instanceof MimeMessage)) {
|
||||
throw new IllegalArgumentException("Message must be a MimeMessage");
|
||||
}
|
||||
MimeMessage mimeMessage = (MimeMessage) message;
|
||||
|
||||
if (!(mimeMessage.getContent() instanceof MimeMultipart)) {
|
||||
throw new AcmeProtocolException("S/MIME signed message must contain MimeMultipart");
|
||||
}
|
||||
|
||||
if (pkixParameters == null) {
|
||||
pkixParameters = new PKIXParameters(getCaCertsTrustStore());
|
||||
}
|
||||
|
||||
// Get the signed message
|
||||
SMIMESigned signed = new SMIMESigned((MimeMultipart) mimeMessage.getContent());
|
||||
|
||||
// Validate the signature
|
||||
SignerInformation si = validateSignature(mimeMessage, pkixParameters);
|
||||
|
||||
// Collect the headers
|
||||
SignedMail result = new SignedMail();
|
||||
|
||||
// First import all untrusted headers from the envelope message
|
||||
result.importUntrustedHeaders(mimeMessage.getAllHeaders());
|
||||
|
||||
// If there is an inner, signed message, import all signed headers
|
||||
MimeMessage content = signed.getContentAsMimeMessage(mailSession);
|
||||
if (content != null && content.isMimeType("message/rfc822")) {
|
||||
MimeMessage protectedBody = new MimeMessage(mailSession, content.getInputStream());
|
||||
if (relaxed) {
|
||||
result.importTrustedHeadersRelaxed(protectedBody.getAllHeaders());
|
||||
} else {
|
||||
result.importTrustedHeaders(protectedBody.getAllHeaders());
|
||||
}
|
||||
}
|
||||
|
||||
// Import secured headers from the signature, if present
|
||||
result.importSignatureHeaders(si);
|
||||
|
||||
// Check if all mandatory headers are trusted
|
||||
Set<String> missing = result.getMissingSecuredHeaders();
|
||||
if (!missing.isEmpty()) {
|
||||
throw new AcmeInvalidMessageException("Secured headers expected, but missing: "
|
||||
+ String.join(", ", missing));
|
||||
}
|
||||
|
||||
// Check if the signer matches the mail sender
|
||||
InternetAddress signerAddress = validateSigatureSender(signed, si);
|
||||
if (!result.getFrom().equals(signerAddress)) {
|
||||
throw new AcmeInvalidMessageException("Message is not signed by the expected sender");
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (IOException | MessagingException | CMSException |
|
||||
KeyStoreException | InvalidAlgorithmParameterException ex) {
|
||||
throw new AcmeInvalidMessageException("Could not validate message signature", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the signature of the signed message.
|
||||
*
|
||||
* @return The {@link SignerInformation} of the valid signature.
|
||||
* @throws AcmeInvalidMessageException
|
||||
* if the signature is invalid, or if the message was signed with more than
|
||||
* one signature.
|
||||
*/
|
||||
private SignerInformation validateSignature(MimeMessage message, PKIXParameters pkixParameters)
|
||||
throws AcmeInvalidMessageException {
|
||||
try {
|
||||
SignedMailValidator smv = new SignedMailValidator(message, pkixParameters);
|
||||
|
||||
SignerInformationStore store = smv.getSignerInformationStore();
|
||||
if (store.size() != 1) {
|
||||
throw new AcmeInvalidMessageException("Expected exactly one signer, but found " + store.size());
|
||||
}
|
||||
return store.getSigners().iterator().next();
|
||||
} catch (SignedMailValidatorException ex) {
|
||||
throw new AcmeInvalidMessageException("Invalid signature", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the signature of the sender. It MUST contain a subjectAltName extension
|
||||
* with a rfc822Name that matches the sender.
|
||||
*
|
||||
* @param signed
|
||||
* {@link SMIMESigned} of the signed message
|
||||
* @param si
|
||||
* {@link SignerInformation} of the message signer
|
||||
* @return The {@link InternetAddress} of the rfc822Name found in the subjectAltName
|
||||
* @throws AcmeInvalidMessageException
|
||||
* if no signature was found, or if the signature has no subjectAltName
|
||||
* extension with rfc822Name.
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private InternetAddress validateSigatureSender(SMIMESigned signed, SignerInformation si)
|
||||
throws AcmeInvalidMessageException {
|
||||
Collection<X509CertificateHolder> certCollection = signed.getCertificates().getMatches(si.getSID());
|
||||
if (certCollection.isEmpty()) {
|
||||
throw new AcmeInvalidMessageException("Could not find certificate for signer ID "
|
||||
+ si.getSID().toString());
|
||||
}
|
||||
X509CertificateHolder ch = certCollection.iterator().next();
|
||||
|
||||
GeneralNames gns = GeneralNames.fromExtensions(ch.getExtensions(), Extension.subjectAlternativeName);
|
||||
if (gns == null) {
|
||||
throw new AcmeInvalidMessageException("Certificate does not have a subjectAltName extension");
|
||||
}
|
||||
|
||||
for (GeneralName name : gns.getNames()) {
|
||||
if (name.getTagNo() == GeneralName.rfc822Name) {
|
||||
try {
|
||||
return new InternetAddress(name.getName().toString());
|
||||
} catch (AddressException ex) {
|
||||
throw new AcmeInvalidMessageException("Invalid certificate email address: "
|
||||
+ name.getName().toString(), ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new AcmeInvalidMessageException("No rfc822Name found in subjectAltName extension");
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a truststore from Java's own cacerts file. The result is cached.
|
||||
*
|
||||
* @return CaCerts truststore
|
||||
*/
|
||||
private static KeyStore getCaCertsTrustStore() {
|
||||
KeyStore caCerts = CACERTS_TRUSTSTORE.get();
|
||||
if (caCerts == null) {
|
||||
String javaHome = System.getProperty("java.home");
|
||||
String caFileName = javaHome + File.separator + "lib" + File.separator
|
||||
+ "security" + File.separator + "cacerts";
|
||||
|
||||
try (InputStream in = new FileInputStream(caFileName)) {
|
||||
caCerts = KeyStore.getInstance("JKS");
|
||||
caCerts.load(in, "changeit".toCharArray());
|
||||
CACERTS_TRUSTSTORE.set(caCerts);
|
||||
} catch (KeyStoreException | IOException | CertificateException |
|
||||
NoSuchAlgorithmException ex) {
|
||||
throw new IllegalStateException("Cannot access cacerts", ex);
|
||||
}
|
||||
}
|
||||
return caCerts;
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,147 @@
|
|||
/*
|
||||
* acme4j - Java ACME client
|
||||
*
|
||||
* Copyright (C) 2023 Richard "Shred" Körber
|
||||
* http://acme4j.shredzone.org
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
*/
|
||||
package org.shredzone.acme4j.smime.wrapper;
|
||||
|
||||
import static java.util.Objects.requireNonNull;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import jakarta.mail.Address;
|
||||
import jakarta.mail.Message;
|
||||
import jakarta.mail.MessagingException;
|
||||
import jakarta.mail.internet.InternetAddress;
|
||||
import org.shredzone.acme4j.smime.exception.AcmeInvalidMessageException;
|
||||
|
||||
/**
|
||||
* Represents a simple, unsigned {@link Message}.
|
||||
* <p>
|
||||
* There is no signature validation at all. Use this class only for testing purposes,
|
||||
* or if a validation has already been performed in a separate step.
|
||||
*
|
||||
* @since 2.16
|
||||
*/
|
||||
public class SimpleMail implements Mail {
|
||||
private static final String HEADER_MESSAGE_ID = "Message-ID";
|
||||
private static final String HEADER_AUTO_SUBMITTED = "Auto-Submitted";
|
||||
|
||||
private final Message message;
|
||||
|
||||
public SimpleMail(Message message) {
|
||||
this.message = requireNonNull(message, "message");
|
||||
}
|
||||
|
||||
@Override
|
||||
public InternetAddress getFrom() throws AcmeInvalidMessageException {
|
||||
try {
|
||||
Address[] from = message.getFrom();
|
||||
if (from == null) {
|
||||
throw new AcmeInvalidMessageException("Missing required 'From' header");
|
||||
}
|
||||
if (from.length != 1) {
|
||||
throw new AcmeInvalidMessageException("Message must have exactly one sender, but has " + from.length);
|
||||
}
|
||||
if (!(from[0] instanceof InternetAddress)) {
|
||||
throw new AcmeInvalidMessageException("Invalid sender message type: " + from[0].getClass().getName());
|
||||
}
|
||||
return (InternetAddress) from[0];
|
||||
} catch (MessagingException ex) {
|
||||
throw new AcmeInvalidMessageException("Could not read 'From' header", ex);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public InternetAddress getTo() throws AcmeInvalidMessageException {
|
||||
try {
|
||||
Address[] to = message.getRecipients(Message.RecipientType.TO);
|
||||
if (to == null) {
|
||||
throw new AcmeInvalidMessageException("Missing required 'To' header");
|
||||
}
|
||||
if (to.length != 1) {
|
||||
throw new AcmeInvalidMessageException("Message must have exactly one recipient, but has " + to.length);
|
||||
}
|
||||
if (!(to[0] instanceof InternetAddress)) {
|
||||
throw new AcmeInvalidMessageException("Invalid recipient message type: " + to[0].getClass().getName());
|
||||
}
|
||||
return (InternetAddress) to[0];
|
||||
} catch (MessagingException ex) {
|
||||
throw new AcmeInvalidMessageException("Could not read 'To' header", ex);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getSubject() throws AcmeInvalidMessageException {
|
||||
try {
|
||||
String subject = message.getSubject();
|
||||
if (subject == null) {
|
||||
throw new AcmeInvalidMessageException("Message must have a subject");
|
||||
}
|
||||
return subject;
|
||||
} catch (MessagingException ex) {
|
||||
throw new AcmeInvalidMessageException("Could not read 'Subject' header", ex);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<InternetAddress> getReplyTo() throws AcmeInvalidMessageException {
|
||||
try {
|
||||
Address[] rto = message.getReplyTo();
|
||||
if (rto == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
return Collections.unmodifiableList(Arrays.stream(rto)
|
||||
.filter(InternetAddress.class::isInstance)
|
||||
.map(InternetAddress.class::cast)
|
||||
.collect(Collectors.toList()));
|
||||
} catch (MessagingException ex) {
|
||||
throw new AcmeInvalidMessageException("Could not read 'Reply-To' header", ex);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<String> getMessageId() throws AcmeInvalidMessageException {
|
||||
try {
|
||||
String[] mid = message.getHeader(HEADER_MESSAGE_ID);
|
||||
if (mid == null || mid.length == 0) {
|
||||
return Optional.empty();
|
||||
}
|
||||
if (mid.length > 1) {
|
||||
throw new AcmeInvalidMessageException("Expected one Message-ID, but found " + mid.length);
|
||||
}
|
||||
return Optional.of(mid[0]);
|
||||
} catch (MessagingException ex) {
|
||||
throw new AcmeInvalidMessageException("Could not read '" + HEADER_MESSAGE_ID + "' header", ex);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isAutoSubmitted() throws AcmeInvalidMessageException {
|
||||
try {
|
||||
String[] autoSubmitted = message.getHeader(HEADER_AUTO_SUBMITTED);
|
||||
if (autoSubmitted == null) {
|
||||
return false;
|
||||
}
|
||||
return Arrays.stream(autoSubmitted)
|
||||
.map(String::trim)
|
||||
.map(String::toLowerCase)
|
||||
.anyMatch(h -> h.equals("auto-generated") || h.startsWith("auto-generated;"));
|
||||
} catch (MessagingException ex) {
|
||||
throw new AcmeInvalidMessageException("Could not read '" + HEADER_AUTO_SUBMITTED + "' header", ex);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,23 @@
|
|||
/*
|
||||
* acme4j - Java ACME client
|
||||
*
|
||||
* Copyright (C) 2023 Richard "Shred" Körber
|
||||
* http://acme4j.shredzone.org
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
*/
|
||||
|
||||
@ReturnValuesAreNonnullByDefault
|
||||
@DefaultAnnotationForParameters(NonNull.class)
|
||||
@DefaultAnnotationForFields(NonNull.class)
|
||||
package org.shredzone.acme4j.smime.wrapper;
|
||||
|
||||
import edu.umd.cs.findbugs.annotations.DefaultAnnotationForFields;
|
||||
import edu.umd.cs.findbugs.annotations.DefaultAnnotationForParameters;
|
||||
import edu.umd.cs.findbugs.annotations.NonNull;
|
||||
import edu.umd.cs.findbugs.annotations.ReturnValuesAreNonnullByDefault;
|
|
@ -15,7 +15,6 @@ package org.shredzone.acme4j.smime.email;
|
|||
|
||||
import static jakarta.mail.Message.RecipientType.TO;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.security.Security;
|
||||
|
@ -83,7 +82,7 @@ public class EmailProcessorTest extends SMIMETests {
|
|||
X509Certificate certificate = readCertificate("valid-signer");
|
||||
EmailProcessor.smimeMessage(message, mailSession, certificate, true);
|
||||
})
|
||||
.withMessage("The S/MIME signature is invalid");
|
||||
.withMessage("Message is not signed by the expected sender");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -94,7 +93,7 @@ public class EmailProcessorTest extends SMIMETests {
|
|||
X509Certificate certificate = readCertificate("valid-signer-nosan");
|
||||
EmailProcessor.smimeMessage(message, mailSession, certificate, true);
|
||||
})
|
||||
.withMessage("Signing certificate does not provide a rfc822Name subjectAltName");
|
||||
.withMessage("Certificate does not have a subjectAltName extension");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -105,7 +104,7 @@ public class EmailProcessorTest extends SMIMETests {
|
|||
X509Certificate certificate = readCertificate("valid-signer");
|
||||
EmailProcessor.smimeMessage(message, mailSession, certificate, true);
|
||||
})
|
||||
.withMessage("Sender 'different-ca@example.com' was not found in signing certificate");
|
||||
.withMessage("Secured header 'From' does not match envelope header");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -116,7 +115,7 @@ public class EmailProcessorTest extends SMIMETests {
|
|||
X509Certificate certificate = readCertificate("valid-signer");
|
||||
EmailProcessor.smimeMessage(message, mailSession, certificate, true);
|
||||
})
|
||||
.withMessage("Protected 'From' header does not match envelope header");
|
||||
.withMessage("Secured header 'From' does not match envelope header");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -127,7 +126,7 @@ public class EmailProcessorTest extends SMIMETests {
|
|||
X509Certificate certificate = readCertificate("valid-signer");
|
||||
EmailProcessor.smimeMessage(message, mailSession, certificate, true);
|
||||
})
|
||||
.withMessage("Protected 'To' header does not match envelope header");
|
||||
.withMessage("Secured header 'To' does not match envelope header");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -138,7 +137,7 @@ public class EmailProcessorTest extends SMIMETests {
|
|||
X509Certificate certificate = readCertificate("valid-signer");
|
||||
EmailProcessor.smimeMessage(message, mailSession, certificate, true);
|
||||
})
|
||||
.withMessage("Protected 'Subject' header does not match envelope header");
|
||||
.withMessage("Secured header 'Subject' does not match envelope header");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
Loading…
Reference in New Issue