001/*
002 * acme4j - Java ACME client
003 *
004 * Copyright (C) 2021 Richard "Shred" Körber
005 *   http://acme4j.shredzone.org
006 *
007 * Licensed under the Apache License, Version 2.0 (the "License");
008 * you may not use this file except in compliance with the License.
009 *
010 * This program is distributed in the hope that it will be useful,
011 * but WITHOUT ANY WARRANTY; without even the implied warranty of
012 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
013 */
014package org.shredzone.acme4j.smime.csr;
015
016import static java.nio.charset.StandardCharsets.UTF_8;
017import static java.util.Objects.requireNonNull;
018import static java.util.stream.Collectors.joining;
019
020import java.io.IOException;
021import java.io.OutputStream;
022import java.io.OutputStreamWriter;
023import java.io.Writer;
024import java.security.KeyPair;
025import java.security.interfaces.ECKey;
026import java.util.ArrayList;
027import java.util.Arrays;
028import java.util.Collection;
029import java.util.List;
030
031import edu.umd.cs.findbugs.annotations.Nullable;
032import jakarta.mail.internet.AddressException;
033import jakarta.mail.internet.InternetAddress;
034import org.bouncycastle.asn1.ASN1ObjectIdentifier;
035import org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers;
036import org.bouncycastle.asn1.x500.X500Name;
037import org.bouncycastle.asn1.x500.X500NameBuilder;
038import org.bouncycastle.asn1.x500.style.BCStyle;
039import org.bouncycastle.asn1.x509.Extension;
040import org.bouncycastle.asn1.x509.ExtensionsGenerator;
041import org.bouncycastle.asn1.x509.GeneralName;
042import org.bouncycastle.asn1.x509.GeneralNames;
043import org.bouncycastle.asn1.x509.KeyUsage;
044import org.bouncycastle.operator.OperatorCreationException;
045import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
046import org.bouncycastle.pkcs.PKCS10CertificationRequest;
047import org.bouncycastle.pkcs.jcajce.JcaPKCS10CertificationRequestBuilder;
048import org.bouncycastle.util.io.pem.PemObject;
049import org.bouncycastle.util.io.pem.PemWriter;
050import org.shredzone.acme4j.Identifier;
051import org.shredzone.acme4j.exception.AcmeProtocolException;
052import org.shredzone.acme4j.smime.EmailIdentifier;
053
054/**
055 * Generator for an S/MIME CSR (Certificate Signing Request) suitable for ACME servers.
056 * <p>
057 * Requires {@code Bouncy Castle}. The {@link org.bouncycastle.jce.provider.BouncyCastleProvider}
058 * must also be added as security provider.
059 * <p>
060 * A {@code javax.mail} implementation must be present in the classpath.
061 *
062 * @since 2.12
063 */
064public class SMIMECSRBuilder {
065    private static final String SIGNATURE_ALG = "SHA256withRSA";
066    private static final String EC_SIGNATURE_ALG = "SHA256withECDSA";
067
068    private final X500NameBuilder namebuilder = new X500NameBuilder(X500Name.getDefaultStyle());
069    private final List<InternetAddress> emaillist = new ArrayList<>();
070    private @Nullable PKCS10CertificationRequest csr = null;
071    private KeyUsageType keyUsageType = KeyUsageType.SIGNING_AND_ENCRYPTION;
072
073    /**
074     * Adds an {@link InternetAddress}. The first address is also used as CN.
075     *
076     * @param email
077     *            {@link InternetAddress} to add
078     */
079    public void addEmail(InternetAddress email) {
080        if (emaillist.isEmpty()) {
081            namebuilder.addRDN(BCStyle.CN, email.getAddress());
082        }
083        emaillist.add(email);
084    }
085
086    /**
087     * Adds multiple {@link InternetAddress}.
088     *
089     * @param emails
090     *            Collection of {@link InternetAddress} to add
091     */
092    public void addEmails(Collection<InternetAddress> emails) {
093        emails.forEach(this::addEmail);
094    }
095
096    /**
097     * Adds multiple {@link InternetAddress}.
098     *
099     * @param emails
100     *            {@link InternetAddress} to add
101     */
102    public void addEmails(InternetAddress... emails) {
103        Arrays.stream(emails).forEach(this::addEmail);
104    }
105
106    /**
107     * Adds an email {@link Identifier}.
108     *
109     * @param id
110     *            {@link Identifier} to add
111     */
112    public void addIdentifier(Identifier id) {
113        requireNonNull(id);
114        if (!EmailIdentifier.TYPE_EMAIL.equals(id.getType())) {
115            throw new AcmeProtocolException("Expected type email, but got " + id.getType());
116        }
117
118        try {
119            addEmail(new InternetAddress(id.getValue()));
120        } catch (AddressException ex) {
121            throw new AcmeProtocolException("bad email address", ex);
122        }
123    }
124
125    /**
126     * Adds a {@link Collection} of email {@link Identifier}.
127     *
128     * @param ids
129     *            Collection of Identifier to add
130     */
131    public void addIdentifiers(Collection<Identifier> ids) {
132        ids.forEach(this::addIdentifier);
133    }
134
135    /**
136     * Adds multiple email {@link Identifier}.
137     *
138     * @param ids
139     *            Identifier to add
140     */
141    public void addIdentifiers(Identifier... ids) {
142        Arrays.stream(ids).forEach(this::addIdentifier);
143    }
144
145    /**
146     * Sets an entry of the subject used for the CSR.
147     * <p>
148     * This method is meant as "expert mode" for setting attributes that are not covered
149     * by the other methods. It is at the discretion of the ACME server to accept this
150     * parameter.
151     *
152     * @param attName
153     *         The BCStyle attribute name
154     * @param value
155     *         The value
156     * @throws AddressException
157     *         if a common name is added, but the value is not a valid email address.
158     * @since 2.14
159     */
160    public void addValue(String attName, String value) throws AddressException {
161        var oid = X500Name.getDefaultStyle().attrNameToOID(requireNonNull(attName, "attribute name must not be null"));
162        addValue(oid, value);
163    }
164
165    /**
166     * Sets an entry of the subject used for the CSR
167     * <p>
168     * This method is meant as "expert mode" for setting attributes that are not covered
169     * by the other methods. It is at the discretion of the ACME server to accept this
170     * parameter.
171     *
172     * @param oid
173     *         The OID of the attribute to be added
174     * @param value
175     *         The value
176     * @throws AddressException
177     *         if a common name is added, but the value is not a valid email address.
178     * @since 2.14
179     */
180    public void addValue(ASN1ObjectIdentifier oid, String value) throws AddressException {
181        if (requireNonNull(oid, "OID must not be null").equals(BCStyle.CN)) {
182            addEmail(new InternetAddress(value));
183            return;
184        }
185        namebuilder.addRDN(oid, requireNonNull(value, "attribute value must not be null"));
186    }
187
188    /**
189     * Sets the organization.
190     * <p>
191     * Note that it is at the discretion of the ACME server to accept this parameter.
192     */
193    public void setOrganization(String o) {
194        namebuilder.addRDN(BCStyle.O, requireNonNull(o));
195    }
196
197    /**
198     * Sets the organizational unit.
199     * <p>
200     * Note that it is at the discretion of the ACME server to accept this parameter.
201     */
202    public void setOrganizationalUnit(String ou) {
203        namebuilder.addRDN(BCStyle.OU, requireNonNull(ou));
204    }
205
206    /**
207     * Sets the city or locality.
208     * <p>
209     * Note that it is at the discretion of the ACME server to accept this parameter.
210     */
211    public void setLocality(String l) {
212        namebuilder.addRDN(BCStyle.L, requireNonNull(l));
213    }
214
215    /**
216     * Sets the state or province.
217     * <p>
218     * Note that it is at the discretion of the ACME server to accept this parameter.
219     */
220    public void setState(String st) {
221        namebuilder.addRDN(BCStyle.ST, requireNonNull(st));
222    }
223
224    /**
225     * Sets the country.
226     * <p>
227     * Note that it is at the discretion of the ACME server to accept this parameter.
228     */
229    public void setCountry(String c) {
230        namebuilder.addRDN(BCStyle.C, requireNonNull(c));
231    }
232
233    /**
234     * Sets the key usage type for S/MIME certificates.
235     * <p>
236     * By default, the S/MIME certificate will be suitable for both signing and
237     * encryption.
238     */
239    public void setKeyUsageType(KeyUsageType keyUsageType) {
240        requireNonNull(keyUsageType, "keyUsageType");
241        this.keyUsageType = keyUsageType;
242    }
243
244    /**
245     * Signs the completed S/MIME CSR.
246     *
247     * @param keypair
248     *            {@link KeyPair} to sign the CSR with
249     */
250    public void sign(KeyPair keypair) throws IOException {
251        requireNonNull(keypair, "keypair");
252        if (emaillist.isEmpty()) {
253            throw new IllegalStateException("No email address was set");
254        }
255
256        try {
257            var ix = 0;
258            var gns = new GeneralName[emaillist.size()];
259            for (var email : emaillist) {
260                gns[ix++] = new GeneralName(GeneralName.rfc822Name, email.getAddress());
261            }
262            var subjectAltName = new GeneralNames(gns);
263
264            var p10Builder = new JcaPKCS10CertificationRequestBuilder(namebuilder.build(), keypair.getPublic());
265
266            var extensionsGenerator = new ExtensionsGenerator();
267            extensionsGenerator.addExtension(Extension.subjectAlternativeName, false, subjectAltName);
268
269            var keyUsage = new KeyUsage(keyUsageType.getKeyUsageBits());
270            extensionsGenerator.addExtension(Extension.keyUsage, true, keyUsage);
271
272            p10Builder.addAttribute(PKCSObjectIdentifiers.pkcs_9_at_extensionRequest, extensionsGenerator.generate());
273
274            var pk = keypair.getPrivate();
275            var csBuilder = new JcaContentSignerBuilder(pk instanceof ECKey ? EC_SIGNATURE_ALG : SIGNATURE_ALG);
276            var signer = csBuilder.build(pk);
277
278            csr = p10Builder.build(signer);
279        } catch (OperatorCreationException ex) {
280            throw new IOException("Could not generate CSR", ex);
281        }
282    }
283
284    /**
285     * Gets the PKCS#10 certification request.
286     */
287    public PKCS10CertificationRequest getCSR() {
288        if (csr == null) {
289            throw new IllegalStateException("sign CSR first");
290        }
291
292        return csr;
293    }
294
295    /**
296     * Gets an encoded PKCS#10 certification request.
297     */
298    public byte[] getEncoded() throws IOException {
299        return getCSR().getEncoded();
300    }
301
302    /**
303     * Writes the signed certificate request to a {@link Writer}.
304     *
305     * @param w
306     *            {@link Writer} to write the PEM file to. The {@link Writer} is closed
307     *            after use.
308     */
309    public void write(Writer w) throws IOException {
310        if (csr == null) {
311            throw new IllegalStateException("sign CSR first");
312        }
313
314        try (var pw = new PemWriter(w)) {
315            pw.writeObject(new PemObject("CERTIFICATE REQUEST", getEncoded()));
316        }
317    }
318
319    /**
320     * Writes the signed certificate request to an {@link OutputStream}.
321     *
322     * @param out
323     *            {@link OutputStream} to write the PEM file to. The {@link OutputStream}
324     *            is closed after use.
325     */
326    public void write(OutputStream out) throws IOException {
327        write(new OutputStreamWriter(out, UTF_8));
328    }
329
330    @Override
331    public String toString() {
332        var sb = new StringBuilder();
333        sb.append(namebuilder.build());
334        if (!emaillist.isEmpty()) {
335            sb.append(emaillist.stream()
336                    .map(InternetAddress::getAddress)
337                    .collect(joining(",EMAIL=", ",EMAIL=", "")));
338        }
339        sb.append(",TYPE=").append(keyUsageType);
340        return sb.toString();
341    }
342
343}