001/* 002 * acme4j - Java ACME client 003 * 004 * Copyright (C) 2015 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.util; 015 016import static org.assertj.core.api.Assertions.*; 017import static org.junit.jupiter.api.Assertions.assertThrows; 018 019import java.io.ByteArrayOutputStream; 020import java.io.IOException; 021import java.io.StringReader; 022import java.io.StringWriter; 023import java.net.InetAddress; 024import java.net.UnknownHostException; 025import java.nio.charset.StandardCharsets; 026import java.security.KeyPair; 027import java.security.Security; 028import java.util.Arrays; 029 030import org.assertj.core.api.AutoCloseableSoftAssertions; 031import org.bouncycastle.asn1.ASN1Encodable; 032import org.bouncycastle.asn1.ASN1IA5String; 033import org.bouncycastle.asn1.ASN1ObjectIdentifier; 034import org.bouncycastle.asn1.DEROctetString; 035import org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers; 036import org.bouncycastle.asn1.x500.X500Name; 037import org.bouncycastle.asn1.x500.style.BCStyle; 038import org.bouncycastle.asn1.x509.Extension; 039import org.bouncycastle.asn1.x509.Extensions; 040import org.bouncycastle.asn1.x509.GeneralName; 041import org.bouncycastle.asn1.x509.GeneralNames; 042import org.bouncycastle.jce.provider.BouncyCastleProvider; 043import org.bouncycastle.openssl.PEMParser; 044import org.bouncycastle.pkcs.PKCS10CertificationRequest; 045import org.junit.jupiter.api.BeforeAll; 046import org.junit.jupiter.api.Test; 047import org.shredzone.acme4j.Identifier; 048 049/** 050 * Unit tests for {@link CSRBuilder}. 051 */ 052public class CSRBuilderTest { 053 054 private static KeyPair testKey; 055 private static KeyPair testEcKey; 056 057 /** 058 * Add provider, create some key pairs 059 */ 060 @BeforeAll 061 public static void setup() { 062 Security.addProvider(new BouncyCastleProvider()); 063 064 testKey = KeyPairUtils.createKeyPair(512); 065 testEcKey = KeyPairUtils.createECKeyPair("secp256r1"); 066 } 067 068 /** 069 * Test if the generated CSR is plausible. 070 */ 071 @Test 072 public void testGenerate() throws IOException { 073 var builder = createBuilderWithValues(); 074 075 builder.sign(testKey); 076 077 var csr = builder.getCSR(); 078 assertThat(csr).isNotNull(); 079 assertThat(csr.getEncoded()).isEqualTo(builder.getEncoded()); 080 081 csrTest(csr); 082 writerTest(builder); 083 } 084 085 /** 086 * Test if the generated CSR is plausible using a ECDSA key. 087 */ 088 @Test 089 public void testECCGenerate() throws IOException { 090 var builder = createBuilderWithValues(); 091 092 builder.sign(testEcKey); 093 094 var csr = builder.getCSR(); 095 assertThat(csr).isNotNull(); 096 assertThat(csr.getEncoded()).isEqualTo(builder.getEncoded()); 097 098 csrTest(csr); 099 writerTest(builder); 100 } 101 102 /** 103 * Make sure an exception is thrown when no domain is set. 104 */ 105 @Test 106 public void testNoDomain() { 107 var ise = assertThrows(IllegalStateException.class, () -> { 108 var builder = new CSRBuilder(); 109 builder.sign(testKey); 110 }); 111 assertThat(ise.getMessage()) 112 .isEqualTo("No domain or IP address was set"); 113 } 114 115 /** 116 * Make sure an exception is thrown when an unknown identifier type is used. 117 */ 118 @Test 119 public void testUnknownType() { 120 var iae = assertThrows(IllegalArgumentException.class, () -> { 121 var builder = new CSRBuilder(); 122 builder.addIdentifier(new Identifier("UnKnOwN", "123")); 123 }); 124 assertThat(iae.getMessage()) 125 .isEqualTo("Unknown identifier type: UnKnOwN"); 126 } 127 128 /** 129 * Make sure all getters will fail if the CSR is not signed. 130 */ 131 @Test 132 public void testNoSign() { 133 var builder = new CSRBuilder(); 134 135 assertThatIllegalStateException() 136 .isThrownBy(builder::getCSR) 137 .as("getCSR()") 138 .withMessage("sign CSR first"); 139 140 assertThatIllegalStateException() 141 .isThrownBy(builder::getEncoded) 142 .as("getCSR()") 143 .withMessage("sign CSR first"); 144 145 assertThatIllegalStateException() 146 .isThrownBy(() -> { 147 try (StringWriter w = new StringWriter()) { 148 builder.write(w); 149 } 150 }) 151 .as("builder.write()") 152 .withMessage("sign CSR first"); 153 } 154 155 /** 156 * Checks that addValue behaves correctly in dependence of the 157 * attributes being added. If a common name is set, it should 158 * be handled in the same way when it's added by using 159 * <code>addDomain</code> 160 */ 161 @Test 162 public void testAddAttrValues() { 163 var builder = new CSRBuilder(); 164 String invAttNameExMessage = assertThrows(IllegalArgumentException.class, 165 () -> X500Name.getDefaultStyle().attrNameToOID("UNKNOWNATT")).getMessage(); 166 167 assertThat(builder.toString()).isEqualTo(""); 168 169 assertThatNullPointerException() 170 .isThrownBy(() -> new CSRBuilder().addValue((String) null, "value")) 171 .as("addValue(String, String)"); 172 assertThatNullPointerException() 173 .isThrownBy(() -> new CSRBuilder().addValue((ASN1ObjectIdentifier) null, "value")) 174 .as("addValue(ASN1ObjectIdentifier, String)"); 175 assertThatNullPointerException() 176 .isThrownBy(() -> new CSRBuilder().addValue("C", null)) 177 .as("addValue(String, null)"); 178 assertThatIllegalArgumentException() 179 .isThrownBy(() -> new CSRBuilder().addValue("UNKNOWNATT", "val")) 180 .as("addValue(String, null)") 181 .withMessage(invAttNameExMessage); 182 183 assertThat(builder.toString()).isEqualTo(""); 184 185 builder.addValue("C", "DE"); 186 assertThat(builder.toString()).isEqualTo("C=DE"); 187 builder.addValue("E", "contact@example.com"); 188 assertThat(builder.toString()).isEqualTo("C=DE,E=contact@example.com"); 189 builder.addValue("CN", "firstcn.example.com"); 190 assertThat(builder.toString()).isEqualTo("C=DE,E=contact@example.com,CN=firstcn.example.com,DNS=firstcn.example.com"); 191 builder.addValue("CN", "scnd.example.com"); 192 assertThat(builder.toString()).isEqualTo("C=DE,E=contact@example.com,CN=firstcn.example.com,DNS=firstcn.example.com,DNS=scnd.example.com"); 193 194 builder = new CSRBuilder(); 195 builder.addValue(BCStyle.C, "DE"); 196 assertThat(builder.toString()).isEqualTo("C=DE"); 197 builder.addValue(BCStyle.EmailAddress, "contact@example.com"); 198 assertThat(builder.toString()).isEqualTo("C=DE,E=contact@example.com"); 199 builder.addValue(BCStyle.CN, "firstcn.example.com"); 200 assertThat(builder.toString()).isEqualTo("C=DE,E=contact@example.com,CN=firstcn.example.com,DNS=firstcn.example.com"); 201 builder.addValue(BCStyle.CN, "scnd.example.com"); 202 assertThat(builder.toString()).isEqualTo("C=DE,E=contact@example.com,CN=firstcn.example.com,DNS=firstcn.example.com,DNS=scnd.example.com"); 203 } 204 205 private CSRBuilder createBuilderWithValues() throws UnknownHostException { 206 var builder = new CSRBuilder(); 207 builder.addDomain("abc.de"); 208 builder.addDomain("fg.hi"); 209 builder.addDomains("jklm.no", "pqr.st"); 210 builder.addDomains(Arrays.asList("uv.wx", "y.z")); 211 builder.addDomain("*.wild.card"); 212 builder.addIP(InetAddress.getByName("192.0.2.1")); 213 builder.addIP(InetAddress.getByName("192.0.2.2")); 214 builder.addIPs(InetAddress.getByName("198.51.100.1"), InetAddress.getByName("198.51.100.2")); 215 builder.addIPs(Arrays.asList(InetAddress.getByName("2001:db8::1"), InetAddress.getByName("2001:db8::2"))); 216 builder.addIdentifier(Identifier.dns("ide1.nt")); 217 builder.addIdentifier(Identifier.ip("203.0.113.5")); 218 builder.addIdentifiers(Identifier.dns("ide2.nt"), Identifier.ip("203.0.113.6")); 219 builder.addIdentifiers(Arrays.asList(Identifier.dns("ide3.nt"), Identifier.ip("203.0.113.7"))); 220 221 builder.setCommonName("abc.de"); 222 builder.setCountry("XX"); 223 builder.setLocality("Testville"); 224 builder.setOrganization("Testing Co"); 225 builder.setOrganizationalUnit("Testunit"); 226 builder.setState("ABC"); 227 228 assertThat(builder.toString()).isEqualTo("CN=abc.de,C=XX,L=Testville,O=Testing Co," 229 + "OU=Testunit,ST=ABC," 230 + "DNS=abc.de,DNS=fg.hi,DNS=jklm.no,DNS=pqr.st,DNS=uv.wx,DNS=y.z,DNS=*.wild.card," 231 + "DNS=ide1.nt,DNS=ide2.nt,DNS=ide3.nt," 232 + "IP=192.0.2.1,IP=192.0.2.2,IP=198.51.100.1,IP=198.51.100.2," 233 + "IP=2001:db8:0:0:0:0:0:1,IP=2001:db8:0:0:0:0:0:2," 234 + "IP=203.0.113.5,IP=203.0.113.6,IP=203.0.113.7"); 235 return builder; 236 } 237 238 /** 239 * Checks if the CSR contains the right parameters. 240 * <p> 241 * This is not supposed to be a Bouncy Castle test. If the 242 * {@link PKCS10CertificationRequest} contains the right parameters, we assume that 243 * Bouncy Castle encodes it properly. 244 */ 245 private void csrTest(PKCS10CertificationRequest csr) { 246 var name = csr.getSubject(); 247 try (var softly = new AutoCloseableSoftAssertions()) { 248 softly.assertThat(name.getRDNs(BCStyle.CN)).as("CN") 249 .extracting(rdn -> rdn.getFirst().getValue().toString()) 250 .contains("abc.de"); 251 softly.assertThat(name.getRDNs(BCStyle.C)).as("C") 252 .extracting(rdn -> rdn.getFirst().getValue().toString()) 253 .contains("XX"); 254 softly.assertThat(name.getRDNs(BCStyle.L)).as("L") 255 .extracting(rdn -> rdn.getFirst().getValue().toString()) 256 .contains("Testville"); 257 softly.assertThat(name.getRDNs(BCStyle.O)).as("O") 258 .extracting(rdn -> rdn.getFirst().getValue().toString()) 259 .contains("Testing Co"); 260 softly.assertThat(name.getRDNs(BCStyle.OU)).as("OU") 261 .extracting(rdn -> rdn.getFirst().getValue().toString()) 262 .contains("Testunit"); 263 softly.assertThat(name.getRDNs(BCStyle.ST)).as("ST") 264 .extracting(rdn -> rdn.getFirst().getValue().toString()) 265 .contains("ABC"); 266 } 267 268 var attr = csr.getAttributes(PKCSObjectIdentifiers.pkcs_9_at_extensionRequest); 269 assertThat(attr).hasSize(1); 270 271 var extensions = attr[0].getAttrValues().toArray(); 272 assertThat(extensions).hasSize(1); 273 274 var names = GeneralNames.fromExtensions((Extensions) extensions[0], Extension.subjectAlternativeName); 275 assertThat(names.getNames()) 276 .filteredOn(gn -> gn.getTagNo() == GeneralName.dNSName) 277 .extracting(gn -> ASN1IA5String.getInstance(gn.getName()).getString()) 278 .containsExactlyInAnyOrder("abc.de", "fg.hi", "jklm.no", "pqr.st", 279 "uv.wx", "y.z", "*.wild.card", "ide1.nt", "ide2.nt", "ide3.nt"); 280 281 assertThat(names.getNames()) 282 .filteredOn(gn -> gn.getTagNo() == GeneralName.iPAddress) 283 .extracting(gn -> getIP(gn.getName()).getHostAddress()) 284 .containsExactlyInAnyOrder("192.0.2.1", "192.0.2.2", "198.51.100.1", 285 "198.51.100.2", "2001:db8:0:0:0:0:0:1", "2001:db8:0:0:0:0:0:2", 286 "203.0.113.5", "203.0.113.6", "203.0.113.7"); 287 } 288 289 /** 290 * Checks if the {@link CSRBuilder#write(java.io.Writer)} method generates a correct 291 * CSR PEM file. 292 */ 293 private void writerTest(CSRBuilder builder) throws IOException { 294 // Write CSR to PEM 295 String pem; 296 try (var out = new StringWriter()) { 297 builder.write(out); 298 pem = out.toString(); 299 } 300 301 // Make sure PEM file is properly formatted 302 assertThat(pem).matches( 303 "-----BEGIN CERTIFICATE REQUEST-----[\\r\\n]+" 304 + "([a-zA-Z0-9/+=]+[\\r\\n]+)+" 305 + "-----END CERTIFICATE REQUEST-----[\\r\\n]*"); 306 307 // Read CSR from PEM 308 PKCS10CertificationRequest readCsr; 309 try (var parser = new PEMParser(new StringReader(pem))) { 310 readCsr = (PKCS10CertificationRequest) parser.readObject(); 311 } 312 313 // Verify that both keypairs are the same 314 assertThat(builder.getCSR()).isNotSameAs(readCsr); 315 assertThat(builder.getEncoded()).isEqualTo(readCsr.getEncoded()); 316 317 // OutputStream is identical? 318 byte[] pemBytes; 319 try (var baos = new ByteArrayOutputStream()) { 320 builder.write(baos); 321 pemBytes = baos.toByteArray(); 322 } 323 assertThat(new String(pemBytes, StandardCharsets.UTF_8)).isEqualTo(pem); 324 } 325 326 /** 327 * Fetches the {@link InetAddress} from the given iPAddress record. 328 * 329 * @param name 330 * Name to convert 331 * @return {@link InetAddress} 332 * @throws IllegalArgumentException 333 * if the IP address could not be read 334 */ 335 private static InetAddress getIP(ASN1Encodable name) { 336 try { 337 return InetAddress.getByAddress(DEROctetString.getInstance(name).getOctets()); 338 } catch (UnknownHostException ex) { 339 throw new IllegalArgumentException(ex); 340 } 341 } 342 343}