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}