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