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}