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}