diff --git a/README.md b/README.md
index c0324666..9a2d8642 100644
--- a/README.md
+++ b/README.md
@@ -15,6 +15,7 @@ It is an independent open source implementation that is not affiliated with or e
* Supports the `http-01`, `dns-01`, and `tls-alpn-01` ([RFC 8737](https://tools.ietf.org/html/rfc8737)) challenges
* Supports [RFC 8738](https://tools.ietf.org/html/rfc8738) IP identifier validation
* Supports [RFC 8739](https://tools.ietf.org/html/rfc8739) short-term automatic certificate renewal (experimental)
+* Supports [RFC 8823](https://tools.ietf.org/html/rfc8823) for S/MIME certificates (experimental)
* Easy to use Java API
* Requires JRE 8 (update 101) or higher. For building the project, Java 9 or higher is required.
* Built with maven, packages available at [Maven Central](http://search.maven.org/#search|ga|1|g%3A%22org.shredzone.acme4j%22)
@@ -25,6 +26,7 @@ It is an independent open source implementation that is not affiliated with or e
* [jose4j](https://bitbucket.org/b_c/jose4j/wiki/Home)
* [slf4j](http://www.slf4j.org/)
* [Bouncy Castle](https://www.bouncycastle.org/) - If you have other means of generating key pairs and CSRs, you can even do without `acme4j-utils` and Bouncy Castle as dependency.
+* For S/MIME certificates: a `javax.mail` implementation (e.g. the [JavaMail Reference Implementation](https://javaee.github.io/javamail/))
## Usage
diff --git a/acme4j-smime/pom.xml b/acme4j-smime/pom.xml
new file mode 100644
index 00000000..d916a91e
--- /dev/null
+++ b/acme4j-smime/pom.xml
@@ -0,0 +1,71 @@
+
+
+
+ * This is the same value that is returned by {@link #getFrom()}, but as {@link + * InternetAddress} instance. + * + * @return Expected sender of the challenge email. + */ + public InternetAddress getExpectedSender() { + try { + return new InternetAddress(getFrom()); + } catch (AddressException ex) { + throw new AcmeProtocolException("bad email address " + getFrom(), ex); + } + } + + /** + * Returns the token, which is a concatenation of the part 1 that is sent by email, + * and part 2 that is passed into this callenge via {@link #getTokenPart2()}; + * + * @param part1 + * Part 1 of the token, which can be found in the subject of the corresponding + * challenge email. + * @return Concatenated token + */ + public String getToken(String part1) { + return part1.concat(getTokenPart2()); + } + + /** + * Returns the part 2 of the token to be used for this challenge. Part 2 is sent via + * this challenge. + */ + public String getTokenPart2() { + return super.getToken(); + } + + /** + * This method is not implemented. Use {@link #getAuthorization(String)} instead. + */ + @Override + public String getAuthorization() { + throw new UnsupportedOperationException("use getAuthorization(String)"); + } + + /** + * Returns the authorization string. + * + * @param part1 + * Part 1 of the token, which can be found in the subject of the corresponding + * challenge email. + */ + public String getAuthorization(String part1) { + String keyAuth = keyAuthorizationFor(getToken(part1)); + return base64UrlEncode(sha256hash(keyAuth)); + } + + @Override + protected boolean acceptable(String type) { + return TYPE.equals(type); + } + +} diff --git a/acme4j-smime/src/main/java/org/shredzone/acme4j/smime/challenge/EmailReply00ChallengeProvider.java b/acme4j-smime/src/main/java/org/shredzone/acme4j/smime/challenge/EmailReply00ChallengeProvider.java new file mode 100644 index 00000000..6750d335 --- /dev/null +++ b/acme4j-smime/src/main/java/org/shredzone/acme4j/smime/challenge/EmailReply00ChallengeProvider.java @@ -0,0 +1,35 @@ +/* + * acme4j - Java ACME client + * + * Copyright (C) 2021 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.challenge; + +import org.shredzone.acme4j.Login; +import org.shredzone.acme4j.challenge.Challenge; +import org.shredzone.acme4j.provider.ChallengeProvider; +import org.shredzone.acme4j.provider.ChallengeType; +import org.shredzone.acme4j.toolbox.JSON; + +/** + * Generates {@link EmailReply00Challenge}. + * + * @since 2.12 + */ +@ChallengeType(EmailReply00Challenge.TYPE) +public class EmailReply00ChallengeProvider implements ChallengeProvider { + + @Override + public Challenge create(Login login, JSON data) { + return new EmailReply00Challenge(login, data); + } + +} diff --git a/acme4j-smime/src/main/java/org/shredzone/acme4j/smime/challenge/package-info.java b/acme4j-smime/src/main/java/org/shredzone/acme4j/smime/challenge/package-info.java new file mode 100644 index 00000000..8f24e3c2 --- /dev/null +++ b/acme4j-smime/src/main/java/org/shredzone/acme4j/smime/challenge/package-info.java @@ -0,0 +1,23 @@ +/* + * acme4j - Java ACME client + * + * Copyright (C) 2021 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.challenge; + +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; diff --git a/acme4j-smime/src/main/java/org/shredzone/acme4j/smime/csr/KeyUsageType.java b/acme4j-smime/src/main/java/org/shredzone/acme4j/smime/csr/KeyUsageType.java new file mode 100644 index 00000000..f2ca69fb --- /dev/null +++ b/acme4j-smime/src/main/java/org/shredzone/acme4j/smime/csr/KeyUsageType.java @@ -0,0 +1,53 @@ +/* + * acme4j - Java ACME client + * + * Copyright (C) 2021 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.csr; + +import org.bouncycastle.asn1.x509.KeyUsage; + +/** + * An enumeration of key usage types for S/MIME certificates. + * + * @since 2.12 + */ +public enum KeyUsageType { + + /** + * S/MIME certificate can be used only for signing. + */ + SIGNING_ONLY(KeyUsage.digitalSignature), + + /** + * S/MIME certificate can be used only for encryption. + */ + ENCRYPTION_ONLY(KeyUsage.keyEncipherment), + + /** + * S/MIME certificate can be used for both signing and encryption. + */ + SIGNING_AND_ENCRYPTION(KeyUsage.digitalSignature | KeyUsage.keyEncipherment); + + private final int keyUsage; + + KeyUsageType(int keyUsage) { + this.keyUsage = keyUsage; + } + + /** + * Returns the key usage bits to be used in the key usage extension of a CSR. + */ + public int getKeyUsageBits() { + return keyUsage; + } + +} diff --git a/acme4j-smime/src/main/java/org/shredzone/acme4j/smime/csr/SMIMECSRBuilder.java b/acme4j-smime/src/main/java/org/shredzone/acme4j/smime/csr/SMIMECSRBuilder.java new file mode 100644 index 00000000..d82f6202 --- /dev/null +++ b/acme4j-smime/src/main/java/org/shredzone/acme4j/smime/csr/SMIMECSRBuilder.java @@ -0,0 +1,305 @@ +/* + * acme4j - Java ACME client + * + * Copyright (C) 2021 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.csr; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.util.Objects.requireNonNull; +import static java.util.stream.Collectors.joining; + +import java.io.IOException; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.io.Writer; +import java.security.KeyPair; +import java.security.PrivateKey; +import java.security.interfaces.ECKey; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; + +import javax.mail.internet.AddressException; +import javax.mail.internet.InternetAddress; + +import edu.umd.cs.findbugs.annotations.Nullable; +import org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x500.X500NameBuilder; +import org.bouncycastle.asn1.x500.style.BCStyle; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.ExtensionsGenerator; +import org.bouncycastle.asn1.x509.GeneralName; +import org.bouncycastle.asn1.x509.GeneralNames; +import org.bouncycastle.asn1.x509.KeyUsage; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.OperatorCreationException; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.bouncycastle.pkcs.PKCS10CertificationRequest; +import org.bouncycastle.pkcs.PKCS10CertificationRequestBuilder; +import org.bouncycastle.pkcs.jcajce.JcaPKCS10CertificationRequestBuilder; +import org.bouncycastle.util.io.pem.PemObject; +import org.bouncycastle.util.io.pem.PemWriter; +import org.shredzone.acme4j.Identifier; +import org.shredzone.acme4j.exception.AcmeProtocolException; +import org.shredzone.acme4j.smime.EmailIdentifier; + +/** + * Generator for an S/MIME CSR (Certificate Signing Request) suitable for ACME servers. + *
+ * Requires {@code Bouncy Castle}. The {@link org.bouncycastle.jce.provider.BouncyCastleProvider} + * must also be added as security provider. + *
+ * A {@code javax.mail} implementation must be present in the classpath.
+ *
+ * @since 2.12
+ */
+public class SMIMECSRBuilder {
+ private static final String SIGNATURE_ALG = "SHA256withRSA";
+ private static final String EC_SIGNATURE_ALG = "SHA256withECDSA";
+
+ private final X500NameBuilder namebuilder = new X500NameBuilder(X500Name.getDefaultStyle());
+ private final List
+ * Note that it is at the discretion of the ACME server to accept this parameter.
+ */
+ public void setOrganization(String o) {
+ namebuilder.addRDN(BCStyle.O, requireNonNull(o));
+ }
+
+ /**
+ * Sets the organizational unit.
+ *
+ * Note that it is at the discretion of the ACME server to accept this parameter.
+ */
+ public void setOrganizationalUnit(String ou) {
+ namebuilder.addRDN(BCStyle.OU, requireNonNull(ou));
+ }
+
+ /**
+ * Sets the city or locality.
+ *
+ * Note that it is at the discretion of the ACME server to accept this parameter.
+ */
+ public void setLocality(String l) {
+ namebuilder.addRDN(BCStyle.L, requireNonNull(l));
+ }
+
+ /**
+ * Sets the state or province.
+ *
+ * Note that it is at the discretion of the ACME server to accept this parameter.
+ */
+ public void setState(String st) {
+ namebuilder.addRDN(BCStyle.ST, requireNonNull(st));
+ }
+
+ /**
+ * Sets the country.
+ *
+ * Note that it is at the discretion of the ACME server to accept this parameter.
+ */
+ public void setCountry(String c) {
+ namebuilder.addRDN(BCStyle.C, requireNonNull(c));
+ }
+
+ /**
+ * Sets the key usage type for S/MIME certificates.
+ *
+ * By default, the S/MIME certificate will be suitable for both signing and
+ * encryption.
+ */
+ public void setKeyUsageType(KeyUsageType keyUsageType) {
+ requireNonNull(keyUsageType, "keyUsageType");
+ this.keyUsageType = keyUsageType;
+ }
+
+ /**
+ * Signs the completed S/MIME CSR.
+ *
+ * @param keypair
+ * {@link KeyPair} to sign the CSR with
+ */
+ public void sign(KeyPair keypair) throws IOException {
+ requireNonNull(keypair, "keypair");
+ if (emaillist.isEmpty()) {
+ throw new IllegalStateException("No email address was set");
+ }
+
+ try {
+ int ix = 0;
+ GeneralName[] gns = new GeneralName[emaillist.size()];
+ for (InternetAddress email : emaillist) {
+ gns[ix++] = new GeneralName(GeneralName.rfc822Name, email.getAddress());
+ }
+ GeneralNames subjectAltName = new GeneralNames(gns);
+
+ PKCS10CertificationRequestBuilder p10Builder =
+ new JcaPKCS10CertificationRequestBuilder(namebuilder.build(), keypair.getPublic());
+
+ ExtensionsGenerator extensionsGenerator = new ExtensionsGenerator();
+ extensionsGenerator.addExtension(Extension.subjectAlternativeName, false, subjectAltName);
+
+ KeyUsage keyUsage = new KeyUsage(keyUsageType.getKeyUsageBits());
+ extensionsGenerator.addExtension(Extension.keyUsage, true, keyUsage);
+
+ p10Builder.addAttribute(PKCSObjectIdentifiers.pkcs_9_at_extensionRequest, extensionsGenerator.generate());
+
+ PrivateKey pk = keypair.getPrivate();
+ JcaContentSignerBuilder csBuilder = new JcaContentSignerBuilder(
+ pk instanceof ECKey ? EC_SIGNATURE_ALG : SIGNATURE_ALG);
+ ContentSigner signer = csBuilder.build(pk);
+
+ csr = p10Builder.build(signer);
+ } catch (OperatorCreationException ex) {
+ throw new IOException("Could not generate CSR", ex);
+ }
+ }
+
+ /**
+ * Gets the PKCS#10 certification request.
+ */
+ public PKCS10CertificationRequest getCSR() {
+ if (csr == null) {
+ throw new IllegalStateException("sign CSR first");
+ }
+
+ return csr;
+ }
+
+ /**
+ * Gets an encoded PKCS#10 certification request.
+ */
+ public byte[] getEncoded() throws IOException {
+ return getCSR().getEncoded();
+ }
+
+ /**
+ * Writes the signed certificate request to a {@link Writer}.
+ *
+ * @param w
+ * {@link Writer} to write the PEM file to. The {@link Writer} is closed
+ * after use.
+ */
+ public void write(Writer w) throws IOException {
+ if (csr == null) {
+ throw new IllegalStateException("sign CSR first");
+ }
+
+ try (PemWriter pw = new PemWriter(w)) {
+ pw.writeObject(new PemObject("CERTIFICATE REQUEST", getEncoded()));
+ }
+ }
+
+ /**
+ * Writes the signed certificate request to an {@link OutputStream}.
+ *
+ * @param out
+ * {@link OutputStream} to write the PEM file to. The {@link OutputStream}
+ * is closed after use.
+ */
+ public void write(OutputStream out) throws IOException {
+ write(new OutputStreamWriter(out, UTF_8));
+ }
+
+ @Override
+ public String toString() {
+ StringBuilder sb = new StringBuilder();
+ sb.append(namebuilder.build());
+ if (!emaillist.isEmpty()) {
+ sb.append(emaillist.stream()
+ .map(InternetAddress::getAddress)
+ .collect(joining(",EMAIL=", ",EMAIL=", "")));
+ }
+ sb.append(",TYPE=").append(keyUsageType);
+ return sb.toString();
+ }
+
+}
diff --git a/acme4j-smime/src/main/java/org/shredzone/acme4j/smime/csr/package-info.java b/acme4j-smime/src/main/java/org/shredzone/acme4j/smime/csr/package-info.java
new file mode 100644
index 00000000..250c8e2c
--- /dev/null
+++ b/acme4j-smime/src/main/java/org/shredzone/acme4j/smime/csr/package-info.java
@@ -0,0 +1,23 @@
+/*
+ * acme4j - Java ACME client
+ *
+ * Copyright (C) 2021 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.csr;
+
+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;
diff --git a/acme4j-smime/src/main/java/org/shredzone/acme4j/smime/email/EmailProcessor.java b/acme4j-smime/src/main/java/org/shredzone/acme4j/smime/email/EmailProcessor.java
new file mode 100644
index 00000000..5e0327c5
--- /dev/null
+++ b/acme4j-smime/src/main/java/org/shredzone/acme4j/smime/email/EmailProcessor.java
@@ -0,0 +1,326 @@
+/*
+ * acme4j - Java ACME client
+ *
+ * Copyright (C) 2021 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.email;
+
+import static java.util.Objects.requireNonNull;
+import static javax.mail.Message.RecipientType.TO;
+
+import java.net.URL;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+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 javax.mail.Address;
+import javax.mail.Message;
+import javax.mail.MessagingException;
+import javax.mail.internet.InternetAddress;
+
+import org.shredzone.acme4j.Identifier;
+import org.shredzone.acme4j.Login;
+import org.shredzone.acme4j.exception.AcmeProtocolException;
+import org.shredzone.acme4j.smime.challenge.EmailReply00Challenge;
+
+/**
+ * A processor for incoming "Challenge" emails.
+ *
+ * Note that according to RFC-8823, the incoming mail must be DKIM or S/MIME signed, and
+ * the signature must be validated. This is not done by this processor, because
+ * it is usually checked by the inbound MTA.
+ *
+ * @see RFC 8823
+ * @since 2.12
+ */
+public final class EmailProcessor {
+
+ private static final Pattern SUBJECT_PATTERN = Pattern.compile("ACME:\\s+([0-9A-Za-z_\\s-]+=?)\\s*");
+
+ private final String token1;
+ private final Optional
+ * The incoming message is validated against the requirements of RFC-8823. An {@link
+ * AcmeProtocolException} is thrown if the validation fails. DKIM or S/MIME signature
+ * is not checked by the processor, and must be checked elsewhere (usually by
+ * the inbound MTA).
+ *
+ * @param message
+ * "Challenge" message as it was sent by the CA.
+ * @throws AcmeProtocolException
+ * if the incoming message is not a valid "challenge" message according to
+ * RFC-8823.
+ */
+ public EmailProcessor(Message message) {
+ requireNonNull(message, "message");
+
+ // Validate challenge and extract token 1
+ try {
+ if (!isAutoGenerated(message)) {
+ throw new AcmeProtocolException("Message is not auto-generated");
+ }
+
+ Address[] from = message.getFrom();
+ if (from.length != 1) {
+ throw new AcmeProtocolException("Message must have exactly one sender, but has " + from.length);
+ }
+ sender = new InternetAddress(from[0].toString());
+
+ Address[] to = message.getRecipients(TO);
+ if (to.length != 1) {
+ throw new AcmeProtocolException("Message must have exactly one recipient, but has " + to.length);
+ }
+ recipient = new InternetAddress(to[0].toString());
+
+ 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+", "");
+
+ Address[] rto = message.getReplyTo();
+ 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 = message.getHeader("Message-ID");
+ 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);
+ }
+ }
+
+ /**
+ * The expected sender of the "challenge" email.
+ *
+ * The sender is usually checked when the {@link EmailReply00Challenge} is passed into
+ * the processor, but you can also manually check the sender here.
+ *
+ * @param expectedSender
+ * The expected sender of the "challenge" email.
+ * @return itself
+ * @throws AcmeProtocolException
+ * if the expected sender does not match
+ */
+ public EmailProcessor expectedFrom(InternetAddress expectedSender) {
+ requireNonNull(expectedSender, "expectedSender");
+ if (!sender.equals(expectedSender)) {
+ throw new AcmeProtocolException("Message is not sent by the expected sender");
+ }
+ return this;
+ }
+
+ /**
+ * The expected recipient of the "challenge" email.
+ *
+ * This must be the email address of the entity that requested the S/MIME certificate.
+ * The check is not performed by the processor, but should be performed by
+ * the client.
+ *
+ * @param expectedRecipient
+ * The expected recipient of the "challenge" email.
+ * @return itself
+ * @throws AcmeProtocolException
+ * if the expected recipient does not match
+ */
+ public EmailProcessor expectedTo(InternetAddress expectedRecipient) {
+ requireNonNull(expectedRecipient, "expectedRecipient");
+ if (!recipient.equals(expectedRecipient)) {
+ throw new AcmeProtocolException("Message is not addressed to expected recipient");
+ }
+ return this;
+ }
+
+ /**
+ * The expected identifier.
+ *
+ * This must be the email address of the entity that requested the S/MIME certificate.
+ * The check is not performed by the processor, but should be performed by
+ * the client.
+ *
+ * @param expectedIdentifier
+ * The expected identifier for the S/MIME certificate. Usually this is an
+ * {@link org.shredzone.acme4j.smime.EmailIdentifier} instance.
+ * @return itself
+ * @throws AcmeProtocolException
+ * if the expected identifier is not an email identifier, or does not match
+ */
+ public EmailProcessor expectedIdentifier(Identifier expectedIdentifier) {
+ requireNonNull(expectedIdentifier, "expectedIdentifier");
+ if (!"email".equals(expectedIdentifier.getType())) {
+ throw new AcmeProtocolException("Wrong identifier type: " + expectedIdentifier.getType());
+ }
+ try {
+ expectedTo(new InternetAddress(expectedIdentifier.getValue()));
+ } catch (MessagingException ex) {
+ throw new AcmeProtocolException("Invalid email address", ex);
+ }
+ return this;
+ }
+
+ /**
+ * Returns the sender of the "challenge" email.
+ */
+ public InternetAddress getSender() {
+ return sender;
+ }
+
+ /**
+ * Returns the recipient of the "challenge" email.
+ */
+ public InternetAddress getRecipient() {
+ return recipient;
+ }
+
+ /**
+ * Returns all "reply-to" email addresses found in the "challenge" email.
+ *
+ * Empty if there was no reply-to header, but never {@code null}.
+ */
+ public Collection
+ * Empty if the challenge email has no message-id.
+ */
+ public Optional
+ * This is a convenience call in case that only the challenge location URL is
+ * available.
+ *
+ * @param login
+ * A valid {@link Login}
+ * @param challengeLocation
+ * The location URL of the corresponding challenge.
+ * @return itself
+ * @throws AcmeProtocolException
+ * if the challenge does not match this "challenge" email.
+ */
+ public EmailProcessor withChallenge(Login login, URL challengeLocation) {
+ return withChallenge(login.bindChallenge(challengeLocation, EmailReply00Challenge.class));
+ }
+
+ /**
+ * Returns the full token of this challenge.
+ *
+ * The corresponding email-reply-00 challenge must be set before.
+ */
+ public String getToken() {
+ checkChallengePresent();
+ return challengeRef.get().getToken(getToken1());
+ }
+
+ /**
+ * Returns the key-authorization of this challenge. This is the response to be used in
+ * the response email.
+ *
+ * The corresponding email-reply-00 challenge must be set before.
+ */
+ public String getAuthorization() {
+ checkChallengePresent();
+ return challengeRef.get().getAuthorization(getToken1());
+ }
+
+ /**
+ * Returns a {@link ResponseGenerator} for generating a response email.
+ *
+ * The corresponding email-reply-00 challenge must be set before.
+ */
+ public ResponseGenerator respond() {
+ checkChallengePresent();
+ return new ResponseGenerator(this);
+ }
+
+ /**
+ * Checks if this message is "auto-generated".
+ *
+ * @param message
+ * Message to check.
+ * @return {@code true} if the mail was auto-generated.
+ */
+ private boolean isAutoGenerated(Message message) throws MessagingException {
+ for (String header : message.getHeader("Auto-Submitted")) {
+ if (header.trim().startsWith("auto-generated")) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Checks if a challenge has been set. Throws an exception if not.
+ */
+ private void checkChallengePresent() {
+ if (challengeRef.get() == null) {
+ throw new IllegalStateException("No challenge has been set yet");
+ }
+ }
+
+}
diff --git a/acme4j-smime/src/main/java/org/shredzone/acme4j/smime/email/ResponseBodyGenerator.java b/acme4j-smime/src/main/java/org/shredzone/acme4j/smime/email/ResponseBodyGenerator.java
new file mode 100644
index 00000000..ae951023
--- /dev/null
+++ b/acme4j-smime/src/main/java/org/shredzone/acme4j/smime/email/ResponseBodyGenerator.java
@@ -0,0 +1,56 @@
+/*
+ * acme4j - Java ACME client
+ *
+ * Copyright (C) 2021 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.email;
+
+import javax.mail.Message;
+import javax.mail.MessagingException;
+
+/**
+ * A generator for the response body to be set to the {@link Message}.
+ *
+ * This generator can be used to design the body of the outgoing response email. However,
+ * note that the response email is evaluated by a machine and usually not read by humans,
+ * so the design should be kept simple, and must be conformous to RFC-8823.
+ *
+ * The {@code responseBody} must be a part of the response email body, otherwise the
+ * validation will fail.
+ *
+ * A minimal implementation is:
+ *
+ * According to RFC-8823, the response email must have a DKIM or S/MIME signature. This is
+ * not done by the response generator, because it is usually performed by the
+ * outbound MTA.
+ *
+ * @see RFC 8823
+ * @since 2.12
+ */
+public class ResponseGenerator {
+ private static final int LINE_LENGTH = 72;
+ private static final String CRLF = "\r\n";
+
+ private final EmailProcessor processor;
+ private ResponseBodyGenerator generator = this::defaultBodyGenerator;
+ private @Nullable String header;
+ private @Nullable String footer;
+
+ /**
+ * Creates a new {@link ResponseGenerator}.
+ *
+ * @param processor
+ * {@link EmailProcessor} of the challenge email.
+ */
+ public ResponseGenerator(EmailProcessor processor) {
+ this.processor = requireNonNull(processor, "processor");
+ }
+
+ /**
+ * Adds a custom header to the response mail body.
+ *
+ * There is no need to set a header, since the response email is usually not read by
+ * humans. If a header is set, it must contain ASCII encoded plain text.
+ *
+ * @param header
+ * Header text to be used, or {@code null} if no header is to be used.
+ * @return itself
+ */
+ public ResponseGenerator withHeader(@Nullable String header) {
+ if (header != null && !header.endsWith(CRLF)) {
+ this.header = header.concat(CRLF);
+ } else {
+ this.header = header;
+ }
+ return this;
+ }
+
+ /**
+ * Adds a custom footer to the response mail body.
+ *
+ * There is no need to set a footer, since the response email is usually not read by
+ * humans. If a footer is set, it must contain ASCII encoded plain text.
+ *
+ * @param footer
+ * Footer text to be used, or {@code null} if no footer is to be used.
+ * @return itself
+ */
+ public ResponseGenerator withFooter(@Nullable String footer) {
+ this.footer = footer;
+ return this;
+ }
+
+ /**
+ * Sets a {@link ResponseBodyGenerator} that is used for generating a response body.
+ *
+ * Use this generator to individually style the email body, for example to use a
+ * multipart body. However be aware that the response mail is evaluated by a machine,
+ * and usually not read by humans, so the body should be designed as simple as
+ * possible.
+ *
+ * The default body generator will just concatenate the header, the armored key
+ * authorization body, and the footer.
+ *
+ * @param generator
+ * {@link ResponseBodyGenerator} to be used, or {@code null} to use the
+ * default one.
+ * @return itself
+ */
+ public ResponseGenerator withGenerator(@Nullable ResponseBodyGenerator generator) {
+ this.generator = generator != null ? generator : this::defaultBodyGenerator;
+ return this;
+ }
+
+ /**
+ * Generates the response email.
+ *
+ * Note that according to RFC-8823, this message must have a valid DKIM or S/MIME
+ * signature. This is not done here, but usually performed by the outbound
+ * MTA.
+ *
+ * @param session
+ * {@code javax.mail} {@link Session} to be used for this mail.
+ * @return Generated {@link Message}.
+ */
+ public Message generateResponse(Session session) throws MessagingException {
+ Message response = new MimeMessage(requireNonNull(session, "session"));
+
+ response.setSubject("Re: ACME: " + processor.getToken1());
+ response.setFrom(processor.getRecipient());
+
+ if (!processor.getReplyTo().isEmpty()) {
+ for (InternetAddress rto : processor.getReplyTo()) {
+ response.addRecipient(TO, rto);
+ }
+ } else {
+ response.addRecipients(TO, new Address[] {processor.getSender()});
+ }
+
+ if (processor.getMessageId().isPresent()) {
+ response.setHeader("In-Reply-To", processor.getMessageId().get());
+ }
+
+ String wrappedAuth = processor.getAuthorization()
+ .replaceAll("(.{" + LINE_LENGTH + "})", "$1" + CRLF);
+ StringBuilder responseBody = new StringBuilder();
+ responseBody.append("-----BEGIN ACME RESPONSE-----").append(CRLF);
+ responseBody.append(wrappedAuth);
+ if (!wrappedAuth.endsWith(CRLF)) {
+ responseBody.append(CRLF);
+ }
+ responseBody.append("-----END ACME RESPONSE-----").append(CRLF);
+
+ generator.setContent(response, responseBody.toString());
+ return response;
+ }
+
+ /**
+ * The default body generator. It just sets the response body, optionally framed by
+ * the given header and footer.
+ *
+ * @param response
+ * response {@link Message} to fill.
+ * @param responseBody
+ * Response body that must be added to the message.
+ */
+ private void defaultBodyGenerator(Message response, String responseBody)
+ throws MessagingException {
+ StringBuilder body = new StringBuilder();
+ if (header != null) {
+ body.append(header);
+ }
+ body.append(responseBody);
+ if (footer != null) {
+ body.append(footer);
+ }
+ response.setContent(body.toString(), RESPONSE_BODY_TYPE);
+ }
+
+}
diff --git a/acme4j-smime/src/main/java/org/shredzone/acme4j/smime/email/package-info.java b/acme4j-smime/src/main/java/org/shredzone/acme4j/smime/email/package-info.java
new file mode 100644
index 00000000..e621a343
--- /dev/null
+++ b/acme4j-smime/src/main/java/org/shredzone/acme4j/smime/email/package-info.java
@@ -0,0 +1,23 @@
+/*
+ * acme4j - Java ACME client
+ *
+ * Copyright (C) 2021 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.email;
+
+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;
diff --git a/acme4j-smime/src/main/java/org/shredzone/acme4j/smime/package-info.java b/acme4j-smime/src/main/java/org/shredzone/acme4j/smime/package-info.java
new file mode 100644
index 00000000..dd16b4ff
--- /dev/null
+++ b/acme4j-smime/src/main/java/org/shredzone/acme4j/smime/package-info.java
@@ -0,0 +1,23 @@
+/*
+ * acme4j - Java ACME client
+ *
+ * Copyright (C) 2021 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;
+
+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;
diff --git a/acme4j-smime/src/main/resources/.gitignore b/acme4j-smime/src/main/resources/.gitignore
new file mode 100644
index 00000000..e69de29b
diff --git a/acme4j-smime/src/main/resources/META-INF/services/org.shredzone.acme4j.provider.ChallengeProvider b/acme4j-smime/src/main/resources/META-INF/services/org.shredzone.acme4j.provider.ChallengeProvider
new file mode 100644
index 00000000..18a54756
--- /dev/null
+++ b/acme4j-smime/src/main/resources/META-INF/services/org.shredzone.acme4j.provider.ChallengeProvider
@@ -0,0 +1,3 @@
+
+# Challenge Provider for https://datatracker.ietf.org/doc/html/rfc8823
+org.shredzone.acme4j.smime.challenge.EmailReply00ChallengeProvider
\ No newline at end of file
diff --git a/acme4j-smime/src/test/java/.gitignore b/acme4j-smime/src/test/java/.gitignore
new file mode 100644
index 00000000..e69de29b
diff --git a/acme4j-smime/src/test/java/org/shredzone/acme4j/smime/EmailIdentifierTest.java b/acme4j-smime/src/test/java/org/shredzone/acme4j/smime/EmailIdentifierTest.java
new file mode 100644
index 00000000..c80541a7
--- /dev/null
+++ b/acme4j-smime/src/test/java/org/shredzone/acme4j/smime/EmailIdentifierTest.java
@@ -0,0 +1,52 @@
+/*
+ * acme4j - Java ACME client
+ *
+ * Copyright (C) 2021 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;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+
+import javax.mail.internet.AddressException;
+import javax.mail.internet.InternetAddress;
+
+import org.junit.Test;
+
+/**
+ * Tests of {@link EmailIdentifier}.
+ */
+public class EmailIdentifierTest {
+
+ @Test
+ public void testConstants() {
+ assertThat(EmailIdentifier.TYPE_EMAIL, is("email"));
+ }
+
+ @Test
+ public void testEmail() throws AddressException {
+ EmailIdentifier id1 = EmailIdentifier.email("email@example.com");
+ assertThat(id1.getType(), is(EmailIdentifier.TYPE_EMAIL));
+ assertThat(id1.getValue(), is("email@example.com"));
+ assertThat(id1.getEmailAddress().getAddress(), is("email@example.com"));
+
+ EmailIdentifier id2 = EmailIdentifier.email(new InternetAddress("email@example.com"));
+ assertThat(id2.getType(), is(EmailIdentifier.TYPE_EMAIL));
+ assertThat(id2.getValue(), is("email@example.com"));
+ assertThat(id2.getEmailAddress().getAddress(), is("email@example.com"));
+
+ EmailIdentifier id3 = EmailIdentifier.email(new InternetAddress("Example Corp
+ * This is not supposed to be a Bouncy Castle test. If the
+ * {@link PKCS10CertificationRequest} contains the right parameters, we assume that
+ * Bouncy Castle encodes it properly.
+ */
+ private void smimeCsrTest(PKCS10CertificationRequest csr) {
+ X500Name name = csr.getSubject();
+ assertThat(name.getRDNs(BCStyle.CN), Matchers.arrayContaining(new RDNMatcher("mail@example.com")));
+ assertThat(name.getRDNs(BCStyle.C), Matchers.arrayContaining(new RDNMatcher("XX")));
+ assertThat(name.getRDNs(BCStyle.L), Matchers.arrayContaining(new RDNMatcher("Testville")));
+ assertThat(name.getRDNs(BCStyle.O), Matchers.arrayContaining(new RDNMatcher("Testing Co")));
+ assertThat(name.getRDNs(BCStyle.OU), Matchers.arrayContaining(new RDNMatcher("Testunit")));
+ assertThat(name.getRDNs(BCStyle.ST), Matchers.arrayContaining(new RDNMatcher("ABC")));
+
+ Attribute[] attr = csr.getAttributes(PKCSObjectIdentifiers.pkcs_9_at_extensionRequest);
+ assertThat(attr.length, is(1));
+ ASN1Encodable[] extensions = attr[0].getAttrValues().toArray();
+ assertThat(extensions.length, is(1));
+ GeneralNames names = GeneralNames.fromExtensions((Extensions) extensions[0], Extension.subjectAlternativeName);
+ assertThat(names.getNames(), Matchers.arrayContaining(
+ new GeneralNameMatcher("mail@example.com", GeneralName.rfc822Name),
+ new GeneralNameMatcher("info@example.com", GeneralName.rfc822Name),
+ new GeneralNameMatcher("sales@example.com", GeneralName.rfc822Name),
+ new GeneralNameMatcher("shop@example.com", GeneralName.rfc822Name),
+ new GeneralNameMatcher("support@example.com", GeneralName.rfc822Name),
+ new GeneralNameMatcher("help@example.com", GeneralName.rfc822Name)));
+ }
+
+ /**
+ * Validate the Key Usage bits.
+ *
+ * @param csr
+ * {@link PKCS10CertificationRequest} to validate
+ * @param expectedUsageBits
+ * Expected key usage bits. Exact match, validation fails if other bits are
+ * set or reset. If {@code null}, validation fails if key usage bits are set.
+ */
+ private void keyUsageTest(PKCS10CertificationRequest csr, Integer expectedUsageBits) {
+ Attribute[] attr = csr.getAttributes(PKCSObjectIdentifiers.pkcs_9_at_extensionRequest);
+ assertThat(attr.length, is(1));
+ ASN1Encodable[] extensions = attr[0].getAttrValues().toArray();
+ assertThat(extensions.length, is(1));
+ DERBitString keyUsageBits = (DERBitString) ((Extensions) extensions[0]).getExtensionParsedValue(Extension.keyUsage);
+ if (expectedUsageBits != null) {
+ assertThat(keyUsageBits.intValue(), is(expectedUsageBits));
+ } else {
+ assertThat(keyUsageBits, is(Matchers.nullValue()));
+ }
+ }
+
+ /**
+ * Checks if the {@link SMIMECSRBuilder#write(java.io.Writer)} method generates a
+ * correct CSR PEM file.
+ */
+ private void writerTest(SMIMECSRBuilder builder) throws IOException {
+ // Write CSR to PEM
+ String pem;
+ try (StringWriter out = new StringWriter()) {
+ builder.write(out);
+ pem = out.toString();
+ }
+
+ // Make sure PEM file is properly formatted
+ assertThat(pem, Matchers.matchesPattern(
+ "-----BEGIN CERTIFICATE REQUEST-----[\\r\\n]+"
+ + "([a-zA-Z0-9/+=]+[\\r\\n]+)+"
+ + "-----END CERTIFICATE REQUEST-----[\\r\\n]*"));
+
+ // Read CSR from PEM
+ PKCS10CertificationRequest readCsr;
+ try (PEMParser parser = new PEMParser(new StringReader(pem))) {
+ readCsr = (PKCS10CertificationRequest) parser.readObject();
+ }
+
+ // Verify that both keypairs are the same
+ assertThat(builder.getCSR(), Matchers.not(Matchers.sameInstance(readCsr)));
+ assertThat(builder.getEncoded(), is(Matchers.equalTo(readCsr.getEncoded())));
+
+ // OutputStream is identical?
+ byte[] pemBytes;
+ try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
+ builder.write(baos);
+ pemBytes = baos.toByteArray();
+ }
+ assertThat(new String(pemBytes, UTF_8), is(Matchers.equalTo(pem)));
+ }
+
+ /**
+ * Make sure an exception is thrown when nothing is set.
+ */
+ @Test(expected = IllegalStateException.class)
+ public void testNoEmail() throws IOException {
+ SMIMECSRBuilder builder = new SMIMECSRBuilder();
+ builder.sign(testKey);
+ }
+
+ /**
+ * Make sure all getters will fail if the CSR is not signed.
+ */
+ @Test
+ public void testNoSign() throws IOException {
+ SMIMECSRBuilder builder = new SMIMECSRBuilder();
+
+ try {
+ builder.getCSR();
+ Assert.fail("getCSR(): expected exception was not thrown");
+ } catch (IllegalStateException ex) {
+ // expected
+ }
+
+ try {
+ builder.getEncoded();
+ Assert.fail("getEncoded(): expected exception was not thrown");
+ } catch (IllegalStateException ex) {
+ // expected
+ }
+
+ try (StringWriter w = new StringWriter()) {
+ builder.write(w);
+ Assert.fail("write(): expected exception was not thrown");
+ } catch (IllegalStateException ex) {
+ // expected
+ }
+ }
+
+ /**
+ * Matches {@link RDN} values.
+ */
+ private static class RDNMatcher extends BaseMatcher
+ * response.setContent(responseBody, RESPONSE_BODY_TYPE);
+ *
+ *
+ * @see RFC 8823
+ * @since 2.12
+ */
+@FunctionalInterface
+public interface ResponseBodyGenerator {
+
+ /**
+ * The content-type of the response body: {@value #RESPONSE_BODY_TYPE}
+ */
+ public static final String RESPONSE_BODY_TYPE = "text/plain";
+
+ /**
+ * Sets the content of the {@link Message}.
+ *
+ * @param response
+ * {@link Message} to set the body content.
+ * @param responseBody
+ * The response body that must be part of the email response, and
+ * must use {@value #RESPONSE_BODY_TYPE} content type.
+ */
+ void setContent(Message response, String responseBody) throws MessagingException;
+
+}
diff --git a/acme4j-smime/src/main/java/org/shredzone/acme4j/smime/email/ResponseGenerator.java b/acme4j-smime/src/main/java/org/shredzone/acme4j/smime/email/ResponseGenerator.java
new file mode 100644
index 00000000..cc7a0f89
--- /dev/null
+++ b/acme4j-smime/src/main/java/org/shredzone/acme4j/smime/email/ResponseGenerator.java
@@ -0,0 +1,178 @@
+/*
+ * acme4j - Java ACME client
+ *
+ * Copyright (C) 2021 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.email;
+
+import static java.util.Objects.requireNonNull;
+import static javax.mail.Message.RecipientType.TO;
+import static org.shredzone.acme4j.smime.email.ResponseBodyGenerator.RESPONSE_BODY_TYPE;
+
+import javax.mail.Address;
+import javax.mail.Message;
+import javax.mail.MessagingException;
+import javax.mail.Session;
+import javax.mail.internet.InternetAddress;
+import javax.mail.internet.MimeMessage;
+
+import edu.umd.cs.findbugs.annotations.Nullable;
+
+/**
+ * A helper for creating an email response to the "challenge" email.
+ *