From 9630737757b2e433f62f03225fc054ac0939c48c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20K=C3=B6rber?= Date: Sat, 3 Jul 2021 10:06:35 +0200 Subject: [PATCH] Add experimental support for RFC 8823 (S/MIME certs) --- README.md | 2 + acme4j-smime/pom.xml | 71 ++++ acme4j-smime/src/main/java/.gitignore | 0 acme4j-smime/src/main/java/module-info.java | 32 ++ .../acme4j/smime/EmailIdentifier.java | 85 ++++ .../challenge/EmailReply00Challenge.java | 126 ++++++ .../EmailReply00ChallengeProvider.java | 35 ++ .../acme4j/smime/challenge/package-info.java | 23 ++ .../acme4j/smime/csr/KeyUsageType.java | 53 +++ .../acme4j/smime/csr/SMIMECSRBuilder.java | 305 +++++++++++++++ .../acme4j/smime/csr/package-info.java | 23 ++ .../acme4j/smime/email/EmailProcessor.java | 326 ++++++++++++++++ .../smime/email/ResponseBodyGenerator.java | 56 +++ .../acme4j/smime/email/ResponseGenerator.java | 178 +++++++++ .../acme4j/smime/email/package-info.java | 23 ++ .../shredzone/acme4j/smime/package-info.java | 23 ++ acme4j-smime/src/main/resources/.gitignore | 0 ...hredzone.acme4j.provider.ChallengeProvider | 3 + acme4j-smime/src/test/java/.gitignore | 0 .../acme4j/smime/EmailIdentifierTest.java | 52 +++ .../shredzone/acme4j/smime/SMIMETests.java | 129 +++++++ .../challenge/EmailReply00ChallengeTest.java | 88 +++++ .../acme4j/smime/csr/SMIMECSRBuilderTest.java | 364 ++++++++++++++++++ .../smime/email/EmailProcessorTest.java | 181 +++++++++ acme4j-smime/src/test/resources/.gitignore | 0 .../src/test/resources/email/challenge.eml | 17 + .../resources/json/emailReplyChallenge.json | 7 + .../json/emailReplyChallengeMismatch.json | 7 + acme4j-smime/src/test/resources/key.pem | 9 + pom.xml | 2 + src/doc/docs/challenge/email-reply-00.md | 73 ++++ src/doc/docs/challenge/index.md | 1 + src/doc/docs/index.md | 7 + src/doc/mkdocs.yml | 2 + 34 files changed, 2303 insertions(+) create mode 100644 acme4j-smime/pom.xml create mode 100644 acme4j-smime/src/main/java/.gitignore create mode 100644 acme4j-smime/src/main/java/module-info.java create mode 100644 acme4j-smime/src/main/java/org/shredzone/acme4j/smime/EmailIdentifier.java create mode 100644 acme4j-smime/src/main/java/org/shredzone/acme4j/smime/challenge/EmailReply00Challenge.java create mode 100644 acme4j-smime/src/main/java/org/shredzone/acme4j/smime/challenge/EmailReply00ChallengeProvider.java create mode 100644 acme4j-smime/src/main/java/org/shredzone/acme4j/smime/challenge/package-info.java create mode 100644 acme4j-smime/src/main/java/org/shredzone/acme4j/smime/csr/KeyUsageType.java create mode 100644 acme4j-smime/src/main/java/org/shredzone/acme4j/smime/csr/SMIMECSRBuilder.java create mode 100644 acme4j-smime/src/main/java/org/shredzone/acme4j/smime/csr/package-info.java create mode 100644 acme4j-smime/src/main/java/org/shredzone/acme4j/smime/email/EmailProcessor.java create mode 100644 acme4j-smime/src/main/java/org/shredzone/acme4j/smime/email/ResponseBodyGenerator.java create mode 100644 acme4j-smime/src/main/java/org/shredzone/acme4j/smime/email/ResponseGenerator.java create mode 100644 acme4j-smime/src/main/java/org/shredzone/acme4j/smime/email/package-info.java create mode 100644 acme4j-smime/src/main/java/org/shredzone/acme4j/smime/package-info.java create mode 100644 acme4j-smime/src/main/resources/.gitignore create mode 100644 acme4j-smime/src/main/resources/META-INF/services/org.shredzone.acme4j.provider.ChallengeProvider create mode 100644 acme4j-smime/src/test/java/.gitignore create mode 100644 acme4j-smime/src/test/java/org/shredzone/acme4j/smime/EmailIdentifierTest.java create mode 100644 acme4j-smime/src/test/java/org/shredzone/acme4j/smime/SMIMETests.java create mode 100644 acme4j-smime/src/test/java/org/shredzone/acme4j/smime/challenge/EmailReply00ChallengeTest.java create mode 100644 acme4j-smime/src/test/java/org/shredzone/acme4j/smime/csr/SMIMECSRBuilderTest.java create mode 100644 acme4j-smime/src/test/java/org/shredzone/acme4j/smime/email/EmailProcessorTest.java create mode 100644 acme4j-smime/src/test/resources/.gitignore create mode 100644 acme4j-smime/src/test/resources/email/challenge.eml create mode 100644 acme4j-smime/src/test/resources/json/emailReplyChallenge.json create mode 100644 acme4j-smime/src/test/resources/json/emailReplyChallengeMismatch.json create mode 100644 acme4j-smime/src/test/resources/key.pem create mode 100644 src/doc/docs/challenge/email-reply-00.md 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 @@ + + + + 4.0.0 + + + org.shredzone.acme4j + acme4j + 2.12-SNAPSHOT + + + acme4j-smime + + acme4j S/MIME + acme4j S/MIME extension + + + + org.shredzone.acme4j + acme4j-client + ${project.version} + + + org.shredzone.acme4j + acme4j-utils + ${project.version} + + + org.bouncycastle + bcprov-jdk15on + ${bouncycastle.version} + + + org.bouncycastle + bcpkix-jdk15on + ${bouncycastle.version} + + + org.slf4j + slf4j-api + ${slf4j.version} + + + javax.mail + javax.mail-api + ${javax.mail.version} + provided + + + com.sun.mail + javax.mail + ${javax.mail.version} + test + + + + diff --git a/acme4j-smime/src/main/java/.gitignore b/acme4j-smime/src/main/java/.gitignore new file mode 100644 index 00000000..e69de29b diff --git a/acme4j-smime/src/main/java/module-info.java b/acme4j-smime/src/main/java/module-info.java new file mode 100644 index 00000000..374e8f8e --- /dev/null +++ b/acme4j-smime/src/main/java/module-info.java @@ -0,0 +1,32 @@ +/* + * 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. + */ + +module org.shredzone.acme4j.smime { + requires org.shredzone.acme4j; + requires org.shredzone.acme4j.utils; + + requires static javax.mail.api; + requires com.github.spotbugs.annotations; + requires org.bouncycastle.pkix; + requires org.bouncycastle.provider; + + exports org.shredzone.acme4j.smime; + exports org.shredzone.acme4j.smime.challenge; + exports org.shredzone.acme4j.smime.csr; + exports org.shredzone.acme4j.smime.email; + + provides org.shredzone.acme4j.provider.ChallengeProvider + with org.shredzone.acme4j.smime.challenge.EmailReply00ChallengeProvider; + +} diff --git a/acme4j-smime/src/main/java/org/shredzone/acme4j/smime/EmailIdentifier.java b/acme4j-smime/src/main/java/org/shredzone/acme4j/smime/EmailIdentifier.java new file mode 100644 index 00000000..ff714b64 --- /dev/null +++ b/acme4j-smime/src/main/java/org/shredzone/acme4j/smime/EmailIdentifier.java @@ -0,0 +1,85 @@ +/* + * 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 javax.mail.internet.AddressException; +import javax.mail.internet.InternetAddress; + +import org.shredzone.acme4j.Identifier; +import org.shredzone.acme4j.exception.AcmeProtocolException; + +/** + * Represents an e-mail identifier. + * + * @since 2.12 + */ +public class EmailIdentifier extends Identifier { + private static final long serialVersionUID = -1473014167038845395L; + + /** + * Type constant for E-Mail identifiers. + * + * @see RFC 8823 + */ + public static final String TYPE_EMAIL = "email"; + + /** + * Creates a new {@link EmailIdentifier}. + * + * @param value + * e-mail address + */ + private EmailIdentifier(String value) { + super(TYPE_EMAIL, value); + } + + /** + * Creates a new email identifier for the given address. + * + * @param email + * Email address. Must only be the address itself (without personal name). + * @return New {@link EmailIdentifier} + */ + public static EmailIdentifier email(String email) { + return new EmailIdentifier(email); + } + + /** + * Creates a new email identifier for the given address. + * + * @param email + * Email address. Only the address itself is used. The personal name will be + * ignored. + * @return New {@link EmailIdentifier} + */ + public static EmailIdentifier email(InternetAddress email) { + return email(email.getAddress()); + } + + /** + * Returns the email address. + * + * @return {@link InternetAddress} + * @throws AcmeProtocolException + * if this is not a valid email identifier. + */ + public InternetAddress getEmailAddress() { + try { + return new InternetAddress(getValue()); + } catch (AddressException ex) { + throw new AcmeProtocolException("bad email address", ex); + } + } + +} diff --git a/acme4j-smime/src/main/java/org/shredzone/acme4j/smime/challenge/EmailReply00Challenge.java b/acme4j-smime/src/main/java/org/shredzone/acme4j/smime/challenge/EmailReply00Challenge.java new file mode 100644 index 00000000..a98c4d79 --- /dev/null +++ b/acme4j-smime/src/main/java/org/shredzone/acme4j/smime/challenge/EmailReply00Challenge.java @@ -0,0 +1,126 @@ +/* + * 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 static org.shredzone.acme4j.toolbox.AcmeUtils.base64UrlEncode; +import static org.shredzone.acme4j.toolbox.AcmeUtils.sha256hash; + +import javax.mail.internet.AddressException; +import javax.mail.internet.InternetAddress; + +import org.shredzone.acme4j.Login; +import org.shredzone.acme4j.challenge.TokenChallenge; +import org.shredzone.acme4j.exception.AcmeProtocolException; +import org.shredzone.acme4j.toolbox.JSON; + +/** + * Implements the {@value TYPE} challenge. + * + * @see RFC 8823 + * @since 2.12 + */ +public class EmailReply00Challenge extends TokenChallenge { + private static final long serialVersionUID = 2502329538019544794L; + + /** + * Challenge type name: {@value} + */ + public static final String TYPE = "email-reply-00"; + + private static final String KEY_FROM = "from"; + + /** + * Creates a new generic {@link EmailReply00Challenge} object. + * + * @param login + * {@link Login} the resource is bound with + * @param data + * {@link JSON} challenge data + */ + public EmailReply00Challenge(Login login, JSON data) { + super(login, data); + } + + /** + * Returns the email address in the "from" field of the challenge. + * + * @return The "from" email address, as String. + */ + public String getFrom() { + return getJSON().get(KEY_FROM).asString(); + } + + /** + * Returns the email address of the expected sender of the "challenge" mail. + *

+ * 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 emaillist = new ArrayList<>(); + private @Nullable PKCS10CertificationRequest csr = null; + private KeyUsageType keyUsageType = KeyUsageType.SIGNING_AND_ENCRYPTION; + + /** + * Adds an {@link InternetAddress}. The first address is also used as CN. + * + * @param email + * {@link InternetAddress} to add + */ + public void addEmail(InternetAddress email) { + if (emaillist.isEmpty()) { + namebuilder.addRDN(BCStyle.CN, email.getAddress()); + } + emaillist.add(email); + } + + /** + * Adds multiple {@link InternetAddress}. + * + * @param emails + * Collection of {@link InternetAddress} to add + */ + public void addEmails(Collection emails) { + emails.forEach(this::addEmail); + } + + /** + * Adds multiple {@link InternetAddress}. + * + * @param emails + * {@link InternetAddress} to add + */ + public void addEmails(InternetAddress... emails) { + Arrays.stream(emails).forEach(this::addEmail); + } + + /** + * Adds an email {@link Identifier}. + * + * @param id + * {@link Identifier} to add + */ + public void addIdentifier(Identifier id) { + requireNonNull(id); + if (!EmailIdentifier.TYPE_EMAIL.equals(id.getType())) { + throw new AcmeProtocolException("Expected type email, but got " + id.getType()); + } + + try { + addEmail(new InternetAddress(id.getValue())); + } catch (AddressException ex) { + throw new AcmeProtocolException("bad email address", ex); + } + } + + /** + * Adds a {@link Collection} of email {@link Identifier}. + * + * @param ids + * Collection of Identifier to add + */ + public void addIdentifiers(Collection ids) { + ids.forEach(this::addIdentifier); + } + + /** + * Adds multiple email {@link Identifier}. + * + * @param ids + * Identifier to add + */ + public void addIdentifiers(Identifier... ids) { + Arrays.stream(ids).forEach(this::addIdentifier); + } + + /** + * Sets the organization. + *

+ * 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 messageId; + private final InternetAddress sender; + private final InternetAddress recipient; + private final Collection replyTo; + private final AtomicReference challengeRef = new AtomicReference<>(); + + /** + * Creates a new {@link EmailProcessor} for the incoming "Challenge" message. + *

+ * 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 getReplyTo() { + return replyTo; + } + + /** + * Returns the message-id of the "challenge" email. + *

+ * Empty if the challenge email has no message-id. + */ + public Optional getMessageId() { + return messageId; + } + + /** + * Returns the "token 1" found in the subject of the "challenge" email. + */ + public String getToken1() { + return token1; + } + + /** + * Sets the corresponding {@link EmailReply00Challenge} that was received from the CA + * for validation. + * + * @param challenge + * {@link EmailReply00Challenge} that corresponds to this email + * @return itself + * @throws AcmeProtocolException + * if the challenge does not match this "challenge" email. + */ + public EmailProcessor withChallenge(EmailReply00Challenge challenge) { + requireNonNull(challenge, "challenge"); + expectedFrom(challenge.getExpectedSender()); + if (challengeRef.get() != null) { + throw new IllegalStateException("A challenge has already been set"); + } + challengeRef.set(challenge); + return this; + } + + /** + * Sets the corresponding {@link EmailReply00Challenge} that was received from the CA + * for validation. + *

+ * 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: + *

+ * 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. + *

+ * 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 ")); + assertThat(id3.getType(), is(EmailIdentifier.TYPE_EMAIL)); + assertThat(id3.getValue(), is("info@example.com")); + assertThat(id3.getEmailAddress().getAddress(), is("info@example.com")); + } + +} \ No newline at end of file diff --git a/acme4j-smime/src/test/java/org/shredzone/acme4j/smime/SMIMETests.java b/acme4j-smime/src/test/java/org/shredzone/acme4j/smime/SMIMETests.java new file mode 100644 index 00000000..62037a4f --- /dev/null +++ b/acme4j-smime/src/test/java/org/shredzone/acme4j/smime/SMIMETests.java @@ -0,0 +1,129 @@ +/* + * 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.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.Reader; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.security.KeyPair; +import java.util.Properties; + +import javax.mail.Message; +import javax.mail.MessagingException; +import javax.mail.Session; +import javax.mail.internet.InternetAddress; +import javax.mail.internet.MimeMessage; + +import org.shredzone.acme4j.Login; +import org.shredzone.acme4j.smime.challenge.EmailReply00Challenge; +import org.shredzone.acme4j.toolbox.JSON; +import org.shredzone.acme4j.util.KeyPairUtils; + +/** + * Some common helper methods for S/MIME unit tests. + */ +public abstract class SMIMETests { + public static final String TOKEN_PART1 = "LgYemJLy3F1LDkiJrdIGbEzyFJyOyf6vBdyZ1TG3sME="; + public static final String TOKEN_PART2 = "DGyRejmCefe7v4NfDGDKfA"; + public static final String TOKEN = TOKEN_PART1 + TOKEN_PART2; + public static final String KEY_AUTHORIZATION = "AjXW0h9_4YMP6Sv-9tKQNUrapI0us7ayBn0nCGOkUsk"; + public static final String RESPONSE_BODY = "-----BEGIN ACME RESPONSE-----\r\n" + + KEY_AUTHORIZATION + "\r\n" + + "-----END ACME RESPONSE-----\r\n"; + + protected final Session mailSession = Session.getInstance(new Properties()); + + /** + * Safely generates an {@link InternetAddress} from the given email address. + */ + protected InternetAddress email(String address) { + try { + return new InternetAddress(address); + } catch (MessagingException ex) { + throw new IllegalArgumentException(ex); + } + } + + /** + * Creates a mock {@link Message}. + * + * @param name + * Name of the mock message to be read from the test resources. + * @return Mock {@link Message} that was created + */ + protected Message mockMessage(String name) { + try (InputStream in = SMIMETests.class.getResourceAsStream("/email/" + name + ".eml")) { + return new MimeMessage(mailSession, in); + } catch (IOException ex) { + throw new UncheckedIOException(ex); + } catch (MessagingException ex) { + throw new IllegalStateException(ex); + } + } + + /** + * Returns a mock account key pair to be used for signing. + */ + protected KeyPair mockAccountKey() { + try (Reader r = new InputStreamReader( + SMIMETests.class.getResourceAsStream("/key.pem"), + StandardCharsets.UTF_8)) { + return KeyPairUtils.readKeyPair(r); + } catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } + + /** + * Returns a mock {@link Login} that can be used for signing. + */ + protected Login mockLogin() { + Login login = mock(Login.class); + when(login.getKeyPair()).thenReturn(mockAccountKey()); + return login; + } + + /** + * Returns a mock {@link EmailReply00Challenge}. + * + * @param name + * Resource name of the mock challenge + * @return Generated {@link EmailReply00Challenge} + */ + protected EmailReply00Challenge mockChallenge(String name) { + return new EmailReply00Challenge(mockLogin(), getJSON(name)); + } + + /** + * Reads a JSON string from json test files and parses it. + * + * @param key + * JSON resource + * @return Parsed JSON resource + */ + protected JSON getJSON(String key) { + try { + return JSON.parse(SMIMETests.class.getResourceAsStream("/json/" + key + ".json")); + } catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } + +} diff --git a/acme4j-smime/src/test/java/org/shredzone/acme4j/smime/challenge/EmailReply00ChallengeTest.java b/acme4j-smime/src/test/java/org/shredzone/acme4j/smime/challenge/EmailReply00ChallengeTest.java new file mode 100644 index 00000000..3997af12 --- /dev/null +++ b/acme4j-smime/src/test/java/org/shredzone/acme4j/smime/challenge/EmailReply00ChallengeTest.java @@ -0,0 +1,88 @@ +/* + * 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 static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; + +import java.net.URI; +import java.net.URL; + +import org.junit.Test; +import org.shredzone.acme4j.Status; +import org.shredzone.acme4j.challenge.Challenge; +import org.shredzone.acme4j.provider.AbstractAcmeProvider; +import org.shredzone.acme4j.provider.AcmeProvider; +import org.shredzone.acme4j.smime.SMIMETests; + +/** + * Unit tests for {@link EmailReply00Challenge}. + */ +public class EmailReply00ChallengeTest extends SMIMETests { + + /** + * Test that the challenge provider is found and the challenge is generated properly. + */ + @Test + public void testCreateChallenge() { + AcmeProvider provider = new TestAcmeProvider(); + + Challenge challenge = provider.createChallenge(mockLogin(), getJSON("emailReplyChallenge")); + assertThat(challenge, not(nullValue())); + assertThat(challenge, instanceOf(EmailReply00Challenge.class)); + } + + /** + * Test that {@link EmailReply00Challenge} generates a correct authorization key. + */ + @Test + public void testEmailReplyChallenge() { + EmailReply00Challenge challenge = new EmailReply00Challenge(mockLogin(), getJSON("emailReplyChallenge")); + + assertThat(challenge.getType(), is(EmailReply00Challenge.TYPE)); + assertThat(challenge.getStatus(), is(Status.PENDING)); + assertThat(challenge.getToken(TOKEN_PART1), is(TOKEN_PART1 + TOKEN_PART2)); + assertThat(challenge.getTokenPart2(), is(TOKEN_PART2)); + assertThat(challenge.getAuthorization(TOKEN_PART1), is(KEY_AUTHORIZATION)); + + assertThat(challenge.getFrom(), is("acme-generator@example.org")); + assertThat(challenge.getExpectedSender().getAddress(), is("acme-generator@example.org")); + } + + /** + * Test that {@link EmailReply00Challenge#getAuthorization()} is not implemented. + */ + @Test(expected = UnsupportedOperationException.class) + public void testInvalidGetAuthorization() { + EmailReply00Challenge challenge = new EmailReply00Challenge(mockLogin(), getJSON("emailReplyChallenge")); + challenge.getAuthorization(); + } + + /** + * A minimal {@link AbstractAcmeProvider} implementation for testing the challenge + * builder. + */ + private static class TestAcmeProvider extends AbstractAcmeProvider { + @Override + public boolean accepts(URI serverUri) { + throw new UnsupportedOperationException(); + } + + @Override + public URL resolve(URI serverUri) { + throw new UnsupportedOperationException(); + } + } + +} diff --git a/acme4j-smime/src/test/java/org/shredzone/acme4j/smime/csr/SMIMECSRBuilderTest.java b/acme4j-smime/src/test/java/org/shredzone/acme4j/smime/csr/SMIMECSRBuilderTest.java new file mode 100644 index 00000000..4551ea63 --- /dev/null +++ b/acme4j-smime/src/test/java/org/shredzone/acme4j/smime/csr/SMIMECSRBuilderTest.java @@ -0,0 +1,364 @@ +/* + * 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 org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.StringReader; +import java.io.StringWriter; +import java.security.KeyPair; +import java.security.Security; +import java.util.Arrays; + +import javax.mail.internet.AddressException; +import javax.mail.internet.InternetAddress; + +import org.bouncycastle.asn1.ASN1Encodable; +import org.bouncycastle.asn1.DERBitString; +import org.bouncycastle.asn1.DERIA5String; +import org.bouncycastle.asn1.pkcs.Attribute; +import org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers; +import org.bouncycastle.asn1.x500.RDN; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x500.style.BCStyle; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.Extensions; +import org.bouncycastle.asn1.x509.GeneralName; +import org.bouncycastle.asn1.x509.GeneralNames; +import org.bouncycastle.asn1.x509.KeyUsage; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.openssl.PEMParser; +import org.bouncycastle.pkcs.PKCS10CertificationRequest; +import org.hamcrest.BaseMatcher; +import org.hamcrest.Description; +import org.hamcrest.Matchers; +import org.junit.Assert; +import org.junit.BeforeClass; +import org.junit.Test; +import org.shredzone.acme4j.util.KeyPairUtils; + +/** + * Unit tests for {@link SMIMECSRBuilder}. + */ +public class SMIMECSRBuilderTest { + + private static KeyPair testKey; + private static KeyPair testEcKey; + + @BeforeClass + public static void setup() { + Security.addProvider(new BouncyCastleProvider()); + + testKey = KeyPairUtils.createKeyPair(512); + testEcKey = KeyPairUtils.createECKeyPair("secp256r1"); + } + + /** + * Test if the generated S/MIME CSR is plausible. + */ + @Test + public void testSMIMEGenerate() throws IOException, AddressException { + SMIMECSRBuilder builder = new SMIMECSRBuilder(); + builder.addEmail(new InternetAddress("Contact ")); + builder.addEmail(new InternetAddress("Info ")); + builder.addEmails(new InternetAddress("Sales Dept "), + new InternetAddress("shop@example.com")); + builder.addEmails(Arrays.asList( + new InternetAddress("support@example.com"), + new InternetAddress("help@example.com")) + ); + + builder.setCountry("XX"); + builder.setLocality("Testville"); + builder.setOrganization("Testing Co"); + builder.setOrganizationalUnit("Testunit"); + builder.setState("ABC"); + + assertThat(builder.toString(), is("CN=mail@example.com,C=XX,L=Testville," + + "O=Testing Co,OU=Testunit,ST=ABC," + + "EMAIL=mail@example.com,EMAIL=info@example.com," + + "EMAIL=sales@example.com,EMAIL=shop@example.com," + + "EMAIL=support@example.com,EMAIL=help@example.com," + + "TYPE=SIGNING_AND_ENCRYPTION")); + + builder.sign(testKey); + + PKCS10CertificationRequest csr = builder.getCSR(); + assertThat(csr, is(Matchers.notNullValue())); + assertThat(csr.getEncoded(), is(Matchers.equalTo(builder.getEncoded()))); + + smimeCsrTest(csr); + keyUsageTest(csr, KeyUsage.digitalSignature | KeyUsage.keyEncipherment); + writerTest(builder); + } + + /** + * Test if the generated S/MIME CSR correctly sets the encryption only flag. + */ + @Test + public void testSMIMEEncryptOnly() throws IOException, AddressException { + SMIMECSRBuilder builder = new SMIMECSRBuilder(); + builder.addEmail(new InternetAddress("mail@example.com")); + builder.setKeyUsageType(KeyUsageType.ENCRYPTION_ONLY); + builder.sign(testKey); + PKCS10CertificationRequest csr = builder.getCSR(); + assertThat(csr, is(Matchers.notNullValue())); + keyUsageTest(csr, KeyUsage.keyEncipherment); + } + + /** + * Test if the generated S/MIME CSR correctly sets the signing only flag. + */ + @Test + public void testSMIMESigningOnly() throws IOException, AddressException { + SMIMECSRBuilder builder = new SMIMECSRBuilder(); + builder.addEmail(new InternetAddress("mail@example.com")); + builder.setKeyUsageType(KeyUsageType.SIGNING_ONLY); + builder.sign(testKey); + PKCS10CertificationRequest csr = builder.getCSR(); + assertThat(csr, is(Matchers.notNullValue())); + keyUsageTest(csr, KeyUsage.digitalSignature); + } + + /** + * Test if the generated S/MIME CSR correctly sets the signing and encryption flag. + */ + @Test + public void testSMIMESigningAndEncryption() throws IOException, AddressException { + SMIMECSRBuilder builder = new SMIMECSRBuilder(); + builder.addEmail(new InternetAddress("mail@example.com")); + builder.setKeyUsageType(KeyUsageType.SIGNING_AND_ENCRYPTION); + builder.sign(testKey); + PKCS10CertificationRequest csr = builder.getCSR(); + assertThat(csr, is(Matchers.notNullValue())); + keyUsageTest(csr, KeyUsage.digitalSignature | KeyUsage.keyEncipherment); + } + + /** + * Checks if the S/MIME CSR contains the right parameters. + *

+ * 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 { + private final String expectedValue; + + public RDNMatcher(String expectedValue) { + this.expectedValue = expectedValue; + } + + @Override + public boolean matches(Object item) { + if (!(item instanceof RDN)) { + return false; + } + return expectedValue.equals(((RDN) item).getFirst().getValue().toString()); + } + + @Override + public void describeTo(Description description) { + description.appendValue(expectedValue); + } + + @Override + public void describeMismatch(Object item, Description description) { + if (!(item instanceof RDN)) { + description.appendText("is a ").appendValue(item.getClass()); + } else { + description.appendText("was ").appendValue(((RDN) item).getFirst().getValue()); + } + } + } + + /** + * Matches {@link GeneralName} DNS tagged values. + */ + private static class GeneralNameMatcher extends BaseMatcher { + private final String expectedValue; + private final int expectedTag; + + public GeneralNameMatcher(String expectedValue, int expectedTag) { + this.expectedTag = expectedTag; + this.expectedValue = expectedValue; + } + + @Override + public boolean matches(Object item) { + if (!(item instanceof GeneralName)) { + return false; + } + + GeneralName gn = (GeneralName) item; + + if (gn.getTagNo() != expectedTag) { + return false; + } + + if (gn.getTagNo() == GeneralName.rfc822Name) { + return expectedValue.equals(DERIA5String.getInstance(gn.getName()).getString()); + } + + return false; + } + + @Override + public void describeTo(Description description) { + description.appendValue(expectedValue); + } + + @Override + public void describeMismatch(Object item, Description description) { + if (!(item instanceof GeneralName)) { + description.appendText("is a ").appendValue(item.getClass()); + return; + } + + GeneralName gn = (GeneralName) item; + if (gn.getTagNo() == GeneralName.rfc822Name) { + description.appendText("was EMAIL ").appendValue(DERIA5String.getInstance(gn.getName()).getString()); + } else { + description.appendText("is not EMAIL, but has tag " + gn.getTagNo()); + } + } + } + +} diff --git a/acme4j-smime/src/test/java/org/shredzone/acme4j/smime/email/EmailProcessorTest.java b/acme4j-smime/src/test/java/org/shredzone/acme4j/smime/email/EmailProcessorTest.java new file mode 100644 index 00000000..0cf9f81a --- /dev/null +++ b/acme4j-smime/src/test/java/org/shredzone/acme4j/smime/email/EmailProcessorTest.java @@ -0,0 +1,181 @@ +/* + * 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 javax.mail.Message.RecipientType.TO; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; + +import java.io.IOException; +import java.util.Optional; + +import javax.mail.Message; +import javax.mail.MessagingException; +import javax.mail.internet.InternetAddress; + +import org.junit.Test; +import org.shredzone.acme4j.Identifier; +import org.shredzone.acme4j.exception.AcmeProtocolException; +import org.shredzone.acme4j.smime.EmailIdentifier; +import org.shredzone.acme4j.smime.SMIMETests; +import org.shredzone.acme4j.smime.challenge.EmailReply00Challenge; + +/** + * Unit tests for {@link EmailProcessor} and {@link ResponseGenerator}. + */ +public class EmailProcessorTest extends SMIMETests { + + private InternetAddress expectedFrom = email("acme-generator@example.org"); + private InternetAddress expectedTo = email("alexey@example.com"); + private InternetAddress expectedReplyTo = email("acme-validator@example.org"); + private Message message = mockMessage("challenge"); + + @Test + public void testEmailParser() throws MessagingException { + EmailProcessor processor = new EmailProcessor(message); + processor.expectedFrom(expectedFrom); + processor.expectedTo(expectedTo); + processor.expectedIdentifier(EmailIdentifier.email(expectedTo)); + processor.expectedIdentifier(new Identifier("email", expectedTo.getAddress())); + + assertThat(processor.getSender(), is(expectedFrom)); + assertThat(processor.getRecipient(), is(expectedTo)); + assertThat(processor.getMessageId(), is(Optional.of(""))); + assertThat(processor.getToken1(), is(TOKEN_PART1)); + assertThat(processor.getReplyTo(), contains(email("acme-validator@example.org"))); + } + + @Test(expected = AcmeProtocolException.class) + public void textExpectedFromFails() { + EmailProcessor processor = new EmailProcessor(message); + processor.expectedFrom(expectedTo); + } + + @Test(expected = AcmeProtocolException.class) + public void textExpectedToFails() { + EmailProcessor processor = new EmailProcessor(message); + processor.expectedTo(expectedFrom); + } + + @Test(expected = AcmeProtocolException.class) + public void textExpectedIdentifierFails1() { + EmailProcessor processor = new EmailProcessor(message); + processor.expectedIdentifier(EmailIdentifier.email(expectedFrom)); + } + + @Test(expected = AcmeProtocolException.class) + public void textExpectedIdentifierFails2() { + EmailProcessor processor = new EmailProcessor(message); + processor.expectedIdentifier(Identifier.ip("192.168.0.1")); + } + + @Test(expected = IllegalStateException.class) + public void textNoChallengeFails1() { + EmailProcessor processor = new EmailProcessor(message); + processor.getToken(); + } + + @Test(expected = IllegalStateException.class) + public void textNoChallengeFails2() { + EmailProcessor processor = new EmailProcessor(message); + processor.getAuthorization(); + } + + @Test(expected = IllegalStateException.class) + public void textNoChallengeFails3() { + EmailProcessor processor = new EmailProcessor(message); + processor.respond(); + } + + @Test + public void testChallenge() { + EmailReply00Challenge challenge = mockChallenge("emailReplyChallenge"); + + EmailProcessor processor = new EmailProcessor(message); + processor.withChallenge(challenge); + assertThat(processor.getToken(), is(TOKEN)); + assertThat(processor.getAuthorization(), is(KEY_AUTHORIZATION)); + assertThat(processor.respond(), is(notNullValue())); + } + + @Test(expected = AcmeProtocolException.class) + public void testChallengeMismatch() { + EmailReply00Challenge challenge = mockChallenge("emailReplyChallengeMismatch"); + EmailProcessor processor = new EmailProcessor(message); + processor.withChallenge(challenge); + } + + @Test + public void testResponse() throws IOException, MessagingException { + EmailReply00Challenge challenge = mockChallenge("emailReplyChallenge"); + + Message response = new EmailProcessor(message) + .withChallenge(challenge) + .respond() + .generateResponse(mailSession); + + assertResponse(response, RESPONSE_BODY); + } + + @Test + public void testResponseWithHeaderFooter() throws IOException, MessagingException { + EmailReply00Challenge challenge = mockChallenge("emailReplyChallenge"); + + Message response = new EmailProcessor(message) + .withChallenge(challenge) + .respond() + .withHeader("This is an introduction.") + .withFooter("This is a footer.") + .generateResponse(mailSession); + + assertResponse(response, + "This is an introduction.\r\n" + + RESPONSE_BODY + + "This is a footer."); + } + + @Test + public void testResponseWithCallback() throws IOException, MessagingException { + EmailReply00Challenge challenge = mockChallenge("emailReplyChallenge"); + + Message response = new EmailProcessor(message) + .withChallenge(challenge) + .respond() + .withGenerator((msg, body) -> msg.setContent("Head\r\n" + body + "Foot", "text/plain")) + .generateResponse(mailSession); + + assertResponse(response, "Head\r\n" + RESPONSE_BODY + "Foot"); + } + + private void assertResponse(Message response, String expectedBody) + throws MessagingException, IOException { + assertThat(response.getContentType(), is("text/plain")); + assertThat(response.getContent().toString(), is(expectedBody)); + + // This is a response, so the expected sender is the recipient of the challenge + assertThat(response.getFrom().length, is(1)); + assertThat(response.getFrom()[0], is(expectedTo)); + + // There is a Reply-To header, so we expect the mail to go only there + assertThat(response.getRecipients(TO).length, is(1)); + assertThat(response.getRecipients(TO)[0], is(expectedReplyTo)); + + assertThat(response.getSubject(), is("Re: ACME: " + TOKEN_PART1)); + + String[] inReplyToHeader = response.getHeader("In-Reply-To"); + assertThat(inReplyToHeader.length, is(1)); + assertThat(inReplyToHeader[0], is("")); + } + +} diff --git a/acme4j-smime/src/test/resources/.gitignore b/acme4j-smime/src/test/resources/.gitignore new file mode 100644 index 00000000..e69de29b diff --git a/acme4j-smime/src/test/resources/email/challenge.eml b/acme4j-smime/src/test/resources/email/challenge.eml new file mode 100644 index 00000000..d80e1979 --- /dev/null +++ b/acme4j-smime/src/test/resources/email/challenge.eml @@ -0,0 +1,17 @@ +Auto-Submitted: auto-generated; type=acme +Date: Sat, 5 Dec 2020 10:08:55 +0100 +Message-ID: +From: acme-generator@example.org +To: Alexey +Reply-To: acme-validator@example.org +Subject: =?UTF-8?Q?ACME:_LgYemJLy3F1LDki=20JrdIGbEzyFJyOyf=20=206vBdyZ1TG3sME=3D?= +Content-Type: text/plain +MIME-Version: 1.0 + +This is an automatically generated ACME challenge for email address +"alexey@example.com". If you haven't requested an S/MIME +certificate generation for this email address, be very afraid. +If you did request it, your email client might be able to process +this request automatically, or you might have to paste the first +token part into an external program. + diff --git a/acme4j-smime/src/test/resources/json/emailReplyChallenge.json b/acme4j-smime/src/test/resources/json/emailReplyChallenge.json new file mode 100644 index 00000000..e3403fc0 --- /dev/null +++ b/acme4j-smime/src/test/resources/json/emailReplyChallenge.json @@ -0,0 +1,7 @@ +{ + "type": "email-reply-00", + "url": "https://example.com/acme/chall/ABprV_B7yEyA4f", + "status": "pending", + "from": "acme-generator@example.org", + "token": "DGyRejmCefe7v4NfDGDKfA" +} \ No newline at end of file diff --git a/acme4j-smime/src/test/resources/json/emailReplyChallengeMismatch.json b/acme4j-smime/src/test/resources/json/emailReplyChallengeMismatch.json new file mode 100644 index 00000000..6efbdc16 --- /dev/null +++ b/acme4j-smime/src/test/resources/json/emailReplyChallengeMismatch.json @@ -0,0 +1,7 @@ +{ + "type": "email-reply-00", + "url": "https://example.com/acme/chall/CoFefB832SAebe", + "status": "pending", + "from": "acme-challenge+2i211oi1204310@example.com", + "token": "IAi2dsjFIDSJsifdj394wf" +} \ No newline at end of file diff --git a/acme4j-smime/src/test/resources/key.pem b/acme4j-smime/src/test/resources/key.pem new file mode 100644 index 00000000..745915d5 --- /dev/null +++ b/acme4j-smime/src/test/resources/key.pem @@ -0,0 +1,9 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIBOgIBAAJBANu2drKTZw3HX3fi0l4yAG5RV/nwl4poPOR+03xHvtQSedLX1CSg +DC6ChkDJ4fqcNMX8LGCSgspj1d0b8gKQDu0CAwEAAQJAYlXkAkDe2tfk7q9iIC6Y +6scVbRQ1fwjwWAQ7e2BRFHDsLNdTvFacwGAhHci83DVIwq6kl7MudUNhXGYi2yvt +MQIhAOnwrcQvFc/g/RkFmWjcKFfzsZGYO2tP2vW4CqDhoFGzAiEA8G5Vw/Yl25vs +QrF8RqouarLghHRG+qN4BpVYQwxZjN8CIQDKRVBpdXC9mcIc1WuMb/bt/QYGZgLS +SWx/0s5VxmAQ4wIgIDT3gi+X9KoXZPu3hRPI8fwSPUwCMhLxwhgBYcHmwQsCIDX5 +IpHdsed3vjY69bjID8LgkIK7BUDGeIkQTMLxKtGb +-----END RSA PRIVATE KEY----- diff --git a/pom.xml b/pom.xml index 9e66cecd..cd150aca 100644 --- a/pom.xml +++ b/pom.xml @@ -53,6 +53,7 @@ 1.68 4.5.13 + 1.6.2 0.7.7 1.7.30 utf-8 @@ -62,6 +63,7 @@ acme4j-client acme4j-utils + acme4j-smime acme4j-example acme4j-it diff --git a/src/doc/docs/challenge/email-reply-00.md b/src/doc/docs/challenge/email-reply-00.md new file mode 100644 index 00000000..98298b06 --- /dev/null +++ b/src/doc/docs/challenge/email-reply-00.md @@ -0,0 +1,73 @@ +# email-reply-00 Challenge + +The `email-reply-00` challenge permits to get end-user S/MIME certificates, as specified in [RFC 8823](https://tools.ietf.org/html/rfc8823). + +The CA must support issuance of S/MIME certificates. _Let's Encrypt_ does not currently support it. + +!!! warning + The support of this challenge is **experimental**. The implementation is only unit tested for compliance with the RFC, but is not integration tested yet. There may be breaking changes in this part of the API in future releases. + +## Setup and Requirements + +To use the S/MIME support, you need to: + +* add the `acme4j-smime` module to your list of dependencies +* make sure that `BouncyCastleProvider` is added as security provider +* add a `javax.mail` implementation to your classpath (e.g. the [JavaMail Reference Implementation](https://javaee.github.io/javamail/)) + +[RFC 8823](https://tools.ietf.org/html/rfc8823) requires that the DKIM or S/MIME signature of incoming mails _must_ be checked. Outgoing mails _must_ have a valid DKIM or S/MIME signature. This is out of the scope of `acme4j-smime`, but is usually performed by a MTA. + +## Ordering + +The certificate ordering process is similar to a standard domain certificate order. + +However, if `Identifier` objects are needed, use `EmailIdentifier.email()` to generate an identifier for the email address you want an S/MIME certificate for. + +To generate a CSR, the module provides a `SMIMECSRBuilder` that works similar to the standard `CSRBuilder`, but accepts `EmailIdentifier` objects. + +With the `SMIMECSRBuilder.setKeyUsageType()`, the desired usage type of the S/MIME certificate can be selected. By default the certificate can be used both for encryption and signing. However this is just a proposal, and the CA is free to ignore it or return an error if the desired usage type is not supported. + +## Challenge and Response + +The CA validates ownership of the email address by two components. + +Firstly, the CA sends a challenge email to the email address that requested the S/MIME certificate. The subject of this email always starts with an `ACME:` prefix, so it can be filtered by the inbound MTA for automatic processing. After the prefix, the mail subject contains the first part of the challenge token (called "Token 1"). + +Secondly, the CA provides a new `EmailReply00Challenge` challenge that needs to be verified by the client. The challenge contains the second part of the challenge token (called "Token 2"). Both token parts are concatenated to give the full token that is required for generating the key authorization. The `EmailReply00Challenge` class offers methods like `getToken(String part1)`, `getTokenPart2()`, and `getAuthorization(String part1)` for that. + +The client now needs to generate a response to the request email. This is a standard mail response to the sender's address. The subject line must be kept, except of an optional `Re:` or a similar prefix. The mail body must contain a `text/plain` part that contains the wrapped key authorization string. For example: + +```text +-----BEGIN ACME RESPONSE----- +LoqXcYV8q5ONbJQxbmR7SCTNo3tiAXDfowy +jxAjEuX0= +-----END ACME RESPONSE----- +``` + +This response is sent back to the CA. + +After that, the `EmailReply00Challenge` is triggered. The CA now has a proof of ownership of the email address, and can issue the S/MIME certificate. + +## Response Helper + +The response process can be executed programatically, or even manually. To help with the process, `acme4j-smime` provides an `EmailProcessor` that helps you parsing the challenge email, and generate a matching response mail. + +It is basically invoked like this: + +```java +Message challengeMessage = // incoming challenge message from the CA +EmailReply00Challenge challenge = // challenge that is requested by the CA +EmailIdentifier identifier = // email address to get the S/MIME cert for +javax.mail.Session mailSession = // javax.mail session + +Message response = new EmailProcessor(challengeMessage) + .expectedIdentifier(identifier) + .withChallenge(challenge) + .respond() + .generateResponse(mailSession); + +Transport.send(response); // send response to the CA +challenge.trigger(); // trigger the challenge +``` + +The `EmailProcessor` and the related `ResponseGenerator` offer more methods for validating and for customizing the response email. diff --git a/src/doc/docs/challenge/index.md b/src/doc/docs/challenge/index.md index ebf367c3..f525e2cd 100644 --- a/src/doc/docs/challenge/index.md +++ b/src/doc/docs/challenge/index.md @@ -12,3 +12,4 @@ The ACME specifications define these standard challenges: _acme4j_ also supports these non-standard challenges: * [tls-alpn-01](tls-alpn-01.md) +* [email-reply-00](email-reply-00.md) diff --git a/src/doc/docs/index.md b/src/doc/docs/index.md index 2f7ad95c..8fbd46da 100644 --- a/src/doc/docs/index.md +++ b/src/doc/docs/index.md @@ -19,6 +19,7 @@ Latest version: ![maven central](https://shredzone.org/maven-central/org.shredzo * 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) @@ -29,6 +30,7 @@ Latest version: ![maven central](https://shredzone.org/maven-central/org.shredzo * [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. +* Only for `acme4j-smime`: a `javax.mail` implementation (e.g. the [JavaMail Reference Implementation](https://javaee.github.io/javamail/)) ## Quick Start @@ -48,6 +50,11 @@ acme4j-utils The Java module name is `org.shredzone.acme4j.utils`. +acme4j-smime +: [`acme4j-smime`](https://mvnrepository.com/artifact/org.shredzone.acme4j/acme4j-smime/latest) contains the [RFC 8823](https://tools.ietf.org/html/rfc8823) implementation for ordering S/MIME certificates. It requires [Bouncy Castle](https://www.bouncycastle.org/java.html) and a `javax.mail` implementation. + + The Java module name is `org.shredzone.acme4j.smime`. + acme4j-example : This module only contains [an example code](example.md) that demonstrates how to get a certificate with _acme4j_. It depends on `acme4j-client` and `acme4j-utils`. It is not useful as a dependency in other projects. diff --git a/src/doc/mkdocs.yml b/src/doc/mkdocs.yml index 9e10892a..52aa9279 100644 --- a/src/doc/mkdocs.yml +++ b/src/doc/mkdocs.yml @@ -21,6 +21,7 @@ nav: - JavaDocs: - 'acme4j-client': acme4j-client/apidocs/index.html - 'acme4j-utils': acme4j-utils/apidocs/index.html + - 'acme4j-smime': acme4j-smime/apidocs/index.html - 'acme4j-it': acme4j-it/apidocs/index.html - Usage: - 'usage/index.md' @@ -35,6 +36,7 @@ nav: - 'challenge/http-01.md' - 'challenge/dns-01.md' - 'challenge/tls-alpn-01.md' + - 'challenge/email-reply-00.md' - CA: - 'ca/index.md' - 'ca/letsencrypt.md'